Peios
The operating system itself — identity, tokens, security descriptors, processes, and the kernel subsystems behind them.
Single-page view · as markdown
Identity in Peios
Peios / Peios Security Fundamentals / Identity
Every action on a running Peios system happens on behalf of a principal — a user, a service, a machine, or a well-known system actor. Identity is the unit of policy: every access decision starts with "who is asking?", and the kernel answers that question the same way for files, registry keys, processes, sockets, and tokens themselves.
The shape of an identity #
Identity in Peios has four layers, each with a clear job.
| Layer | Role |
|---|---|
| Principal | The thing being identified — a user account, a group, a service, a machine. |
| SID (Security Identifier) | The unique name for a principal. SIDs are hierarchical strings like S-1-5-21-...-1001. |
| Token | The runtime object that carries an identity into the kernel. Every thread has one. |
| Logon session | The authentication event a token belongs to. Sessions link tokens back to "who logged in, when, and how". |
These four words appear throughout the Peios documentation, and they are not interchangeable. A principal is the long-lived thing in the directory; a SID is its name; a token is its instance for one run; a session is what produced that token.
How an identity gets into the kernel #
flowchart LR
A["Principal"] -->|"identity asserted by"| B["Principal source"]
B -->|"verified identity"| C["authd"]
C -->|creates| D["Logon session"]
C -->|mints| E["Token"]
E -->|installed on| F["Process"]
F -->|"presents at every syscall"| G["Access decision"]
When a user signs in, authentication happens in three steps:
- A principal source verifies the credential and asserts who the principal is — the local principal source for this machine's own accounts, or a directory-backed source on a domain-joined machine.
- authd, the authentication authority, creates a logon session: a kernel object that records the authentication event (who, how, when).
- authd mints a token carrying the user's SID, group SIDs, integrity level, privileges, and a reference to the session. The token becomes the user's runtime identity — and its privileges and integrity level come from this machine's policy for the principal, not from the source that identified them.
The client that requested the logon — login on a console, for example — installs the token on the session's first process, and every child process inherits it across fork. Threads that need to act on behalf of someone else (for example, a service handling a user request) can temporarily swap in an impersonation token while keeping their original — see Impersonation.
The token is the authoritative carrier. Any other identity values a process can observe — including the numeric IDs that surface through standard Linux system calls — are derived from the token, never the other way around. See Linux compatibility for how that projection works.
Identity vs authorisation #
An identity by itself grants nothing. A token says who a thread is; the Kernel Access Control Subsystem (KACS) decides what it can do, by comparing the token against each object's security descriptor. The same token will see different rights on different objects, and the same object will grant different rights to different tokens.
The rest of the security documentation is organised around this separation:
- Identity says "who". This topic, plus Tokens and Logon sessions.
- Authorisation says "what". Security descriptors and Access decisions.
If a request fails unexpectedly, you almost always need to look at both sides: which identity made the request, and which access rule rejected it. Pages in both halves cross-link where the two meet.
What identity does not include #
A few things often get bundled with identity that are kept deliberately separate in Peios:
- Privileges are not identity. A token carries a set of privileges, but a privilege gates a specific operation (loading a driver, taking ownership of an object), independent of who you are. See Privileges.
- Integrity level is not identity. It is a separate axis on the token that controls write-up restrictions. Two users at different integrity levels are still two distinct identities; the integrity level is an additional constraint on top.
- Process integrity (PIP) is not identity at all. PIP is a property of the binary the process is running, set by the kernel from the binary's signature. It controls who can inspect or signal the process, independent of who the process is acting as. See Process integrity protection.
These distinctions matter because they fail in different ways. An "access denied" caused by the integrity level looks like an identity problem but is not.
Where to start #
If you want to understand how SIDs are constructed, read SIDs. For the catalog of built-in principals and their fixed SIDs, read Well-known principals.
If you want the typed attributes a token carries beyond identity — the inputs to attribute-based access decisions — read Claims on a token.
If you want the runtime mechanics — how a token is composed, how it survives fork and exec, and how it gets adjusted — read Tokens.
If you are debugging an unexpected denial, the Inspecting tokens, sessions, and processes topic shows how to see the identity attached to any running thread.
SIDs
Peios / Peios Security Fundamentals / Identity
A SID (Security Identifier) is the unique name for a principal. Every user, group, service, machine, and well-known system actor has exactly one SID, and that SID is how every other part of Peios refers to them: ACEs in a security descriptor name SIDs, tokens carry SIDs, audit events log SIDs.
Two SIDs are equal only if their binary encodings match byte-for-byte. There is no normalisation and no case folding. A SID is its bytes.
The string form #
The form you will see in logs, configuration, and almost every administrative tool is the string form:
S-1-5-21-3623811015-3361044348-30300820-1001
The pieces:
| Position | Meaning |
|---|---|
S | Constant marker — every SID string starts with S. |
1 | Revision number. Always 1 in Peios. |
5 | Identifier authority. A small integer naming the SID's overall namespace. |
21-3623811015-3361044348-30300820 | Sub-authorities. A sequence of integers that locate this principal within the authority's namespace. |
1001 | Relative identifier (RID). The last sub-authority. Distinguishes individual principals within a domain. |
A SID has between 1 and 15 sub-authorities. Most have between 1 and 7. The RID is just whatever sub-authority comes last; "RID" is a name for its role in domain SIDs, not a separate field.
When the authority is written in hex #
The identifier authority is a 48-bit value. When it fits in 32 bits — which it does for every authority Peios uses — it is written in decimal. If the upper 16 bits are nonzero, the authority is instead written as a 0x-prefixed 12-digit hexadecimal value:
S-1-0x000000123456-1-2-3
You will not see this form in practice on Peios. It exists for parity with external systems that allocate from the upper authority range.
The binary form #
On the wire (in security descriptors, in tokens, in audit events), a SID is a packed byte structure:
+----+----+----+----+----+----+----+----+
| 01 | NN | AA AA AA AA AA AA |
+----+----+----+----+----+----+----+----+
| sub_1 (4 bytes, little-endian) |
+----+----+----+----+----+----+----+----+
| sub_2 (4 bytes, little-endian) |
+----+----+----+----+----+----+----+----+
| ... |
+----+----+----+----+----+----+----+----+
| sub_N (4 bytes, little-endian) |
+----+----+----+----+----+----+----+----+
The first byte is the revision (0x01), the second the sub-authority count, then the six-byte authority, then the sub-authorities. The canonical byte-level layout is in the wire formats reference, under "SIDs in wire format".
The mixed endianness — big-endian for the authority, little-endian for the sub-authorities — is the one detail worth memorising. The rest of KACS is uniformly little-endian; the SID authority is the exception, for compatibility with the way SIDs travel across federation boundaries.
A SID is between 8 bytes (no sub-authorities, very rare) and 68 bytes (15 sub-authorities) long.
Comparison #
Two SIDs are equal if and only if their binary representations are identical byte-for-byte.
This rule has consequences worth knowing:
- The string form is for humans. Two strings that look equivalent might not encode to the same bytes if one was reconstructed by hand and got a leading zero wrong, or if one used hex authority where decimal would do. Always compare in binary.
- There is no case folding. SIDs do not contain letters in their numeric form, so this rarely matters, but tools that print SIDs alongside resolved names — for example
BUILTIN\AdministratorsforS-1-5-32-544— must compare the SID, not the name. - The kernel only compares for equality. When KACS matches a SID in an ACE against the SIDs in a token, it tests the full encoded bytes for equality — there is no "same domain" or "prefix match" at the access-check level. Userspace tooling can parse SIDs and compare them structurally for its own purposes (listing all users in a domain, filtering by authority, reporting), and is expected to. That parsing is on top of the byte representation; the underlying bytes are still what define identity.
If you are writing code that handles SIDs, store them as their binary form whenever possible and convert to strings only at the UI boundary.
SID patterns you will recognise #
The sub-authorities of a SID encode meaning by convention, not by parse rule. A few patterns appear so often that you will start to recognise them by shape:
| Pattern | Meaning |
|---|---|
S-1-5-32-... | A built-in alias group under the NT Authority — local administrators, users, guests, etc. |
S-1-5-21-W-X-Y-... | A principal in a specific domain. The three numbers after 21 identify the domain; the last sub-authority is the RID. |
S-1-5-5-X-Y | A logon SID. Created per authentication event; lets ACEs target "the specific login session that did this". |
S-1-16-N | An integrity level. Used as a label in a SACL, not in a DACL. |
S-1-19-T-L | A process integrity protection (PIP) trust label. Used in SACLs on objects that opt in to PIP. |
S-1-5-80-h1-h2-h3-h4-h5 | A service SID, derived from a service name. Lets ACEs target a specific service independent of the account it runs under. |
S-1-15-3-h1-...-h8 | A capability SID, derived from a capability name. Used in confinement to grant a specific capability to a sandboxed application. |
The full catalog of named SIDs is in Well-known principals. The derivation rules for service SIDs and capability SIDs — where the hash values actually come from — are documented under their respective topics.
What a SID does not tell you #
A SID identifies a principal but says nothing else about them. From a SID alone you cannot tell:
- Which groups the principal is a member of (the token's group list does that).
- What integrity level the principal runs at (the token's
integrity_levelfield). - What privileges the principal has (the token's privilege bitmask).
- Whether the principal is currently logged in (the logon session does that).
A SID is just the name. Everything else about a principal lives somewhere else — usually on the token that carries the SID at runtime.
Where to go next #
For the catalog of SIDs whose values are fixed by the system — Everyone, SYSTEM, the BUILTIN aliases, and the rest — read Well-known principals.
For the typed attributes that travel alongside SIDs on a token, read Claims on a token.
For how a SID is carried into every access decision at runtime, read Tokens.
Well-known principals
Peios / Peios Security Fundamentals / Identity
A well-known principal is a principal whose SID is fixed by the system rather than allocated when an account is created. Some name a single specific entity (SYSTEM, Anonymous). Some name a category (Everyone, Authenticated Users). Some are placeholders that get rewritten during inheritance (CREATOR OWNER). All of them have the same purpose: they let you write ACEs that target a role without naming a specific user.
You will recognise the common ones quickly; the full catalog exists so you can look up the rest when they appear in an audit log or an ACE.
How to read the catalog #
Each well-known SID has a fixed numeric value, a conventional name, and a defined behaviour during access checks. Some of them are not real principals at all — they are placeholders or labels that the access check treats specially. Where that distinction matters, it is called out.
The catalog is organised by SID authority, because that is how the SIDs are structured and how you will tend to recognise them.
Universal SIDs (S-1-0, S-1-1, S-1-2, S-1-3) #
These authorities are defined globally — not tied to any specific machine or domain — and exist for cross-system meaning.
| SID | Name | Behaviour |
|---|---|---|
S-1-0-0 | Nobody | Matches nothing. Used to write an ACE that can never apply, usually as a deliberate placeholder. |
S-1-1-0 | Everyone | Matches every token, including Anonymous. The broadest possible grant. |
S-1-2-0 | Local | Matches any token created by a local logon (not a network logon). |
S-1-2-1 | Console Logon | Matches a token created by a physical or console session. |
S-1-3-0 | Creator Owner | Placeholder. Inheritable ACEs containing this SID are rewritten to the new object's owner SID when the child is created. Does not match anything at access-check time. |
S-1-3-1 | Creator Group | Placeholder. Same as Creator Owner but for the primary group. |
S-1-3-4 | Owner Rights | Special. When present in a DACL, it suppresses the implicit READ_CONTROL and WRITE_DAC grants that the owner would otherwise receive. See Ownership and implicit rights. |
The placeholder SIDs (S-1-3-0 and S-1-3-1) are the trickiest. They never grant or deny anything at access-check time. Their job is to make inheritable ACEs portable across owners — you write an ACE that grants WRITE to Creator Owner, and when a child object is created, that ACE is rewritten to grant WRITE to whoever owns the child.
NT Authority SIDs (S-1-5) #
This is the largest group of well-known SIDs and where most of the principals you will use day-to-day live.
Logon classifiers #
| SID | Name | Behaviour |
|---|---|---|
S-1-5-2 | Network | Matches a token created by a network logon. |
S-1-5-4 | Interactive | Matches a token created by an interactive logon (console, RDP, SSH). |
S-1-5-6 | Service | Matches a token created for a service. |
S-1-5-7 | Anonymous | The user SID for tokens at the Anonymous impersonation level. |
S-1-5-11 | Authenticated Users | Matches every successfully authenticated token. Excludes Anonymous. |
The pair S-1-1-0 (Everyone) and S-1-5-11 (Authenticated Users) is the most common distinction worth getting right. Everyone includes Anonymous; Authenticated Users excludes it. Anything reachable by Anonymous is reachable by an unauthenticated network peer, which is almost never what you want.
System actors #
| SID | Name | Behaviour |
|---|---|---|
S-1-5-18 | Local System (SYSTEM) | The kernel and TCB services run as SYSTEM. The most privileged identity on the local machine. |
S-1-5-19 | Local Service | Built-in service account with reduced privileges. |
S-1-5-20 | Network Service | Built-in service account that can authenticate outward to remote machines. |
Built-in aliases #
The sub-authority 32 under NT Authority is the "BUILTIN" domain, which holds aliases for common administrative roles. The full alias list includes a few dozen entries; the ones you will see most often:
| SID | Name |
|---|---|
S-1-5-32-544 | BUILTIN\Administrators |
S-1-5-32-545 | BUILTIN\Users |
S-1-5-32-546 | BUILTIN\Guests |
S-1-5-32-551 | BUILTIN\Backup Operators |
Logon sessions and domains #
| Pattern | Meaning |
|---|---|
S-1-5-5-X-Y | A specific logon session. X and Y are derived from the session's LUID. Always carried in the token of any thread belonging to that session. |
S-1-5-21-DA1-DA2-DA3-RID | A principal in a specific domain. DA1-DA2-DA3 identifies the domain; RID identifies the principal within it. |
S-1-5-21-DA1-DA2-DA3-500 | The Administrator account of that domain (RID 500 is reserved). |
S-1-5-21-DA1-DA2-DA3-512 | Domain Admins. |
S-1-5-21-DA1-DA2-DA3-513 | Domain Users. |
S-1-5-21-DA1-DA2-DA3-515 | Domain Computers — the machine accounts in the domain. |
The full set of reserved RIDs under S-1-5-21-* follows administrative convention. Domain-specific principals (your actual users) get RIDs allocated above 1000.
Service SIDs #
A SID derived from a service's name, used to grant a service ACL entries independent of the account it runs under.
| Pattern | Meaning |
|---|---|
S-1-5-80-h1-h2-h3-h4-h5 | A service SID. The five hash values come from the SHA-1 of the UTF-16LE uppercased service name, split into five little-endian 32-bit integers. |
Service SIDs let you say "grant the loregd service read access to this file" without saying "grant SYSTEM read access" or "grant a specific user read access" — both of which would be too broad.
Mandatory integrity labels (S-1-16) #
These are not principals. They are labels carried in a token's integrity level field and in an object's mandatory label ACE.
| SID | Level |
|---|---|
S-1-16-0 | Untrusted |
S-1-16-4096 | Low |
S-1-16-8192 | Medium |
S-1-16-12288 | High |
S-1-16-16384 | System |
These five are the standard, well-known levels. The level is really the SID's single sub-authority as an unsigned integer, compared numerically, so any S-1-16-<n> with exactly one sub-authority is valid — non-standard values such as medium-plus (S-1-16-8448) and protected (S-1-16-20480) appear for Windows interop.
Integrity levels are a vertical axis on top of identity. A user at Medium integrity and the same user at High integrity have the same SID but different integrity labels. See Mandatory integrity control for how the levels interact with access checks.
Process trust labels (S-1-19) #
Like integrity labels, these are not principals. They label processes by signing trust for Process integrity protection.
| Pattern | Meaning |
|---|---|
S-1-19-T-L | PIP trust label. T is the PIP type (0 None, 512 Protected, 1024 Isolated). L is the trust level within that type. |
Common combinations:
| SID | Trust meaning |
|---|---|
S-1-19-0-0 | No PIP protection. Default for unsigned processes. |
S-1-19-512-1024 | Protected, Authenticode. Third-party signed binaries. |
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. Trusted computing base. |
Confinement and capability SIDs (S-1-15) #
These are used by Confinement to label sandboxed applications and the capabilities they have been granted.
Confinement domains #
| SID | Name |
|---|---|
S-1-15-2-1 | ALL_APPLICATION_PACKAGES — matches every confined application in normal confinement mode. |
S-1-15-2-2 | ALL_RESTRICTED_APPLICATION_PACKAGES — matches confined applications in both normal and strict modes. |
Well-known capabilities #
| SID | Capability |
|---|---|
S-1-15-3-1 | internetClient — outbound network. |
S-1-15-3-2 | internetClientServer — inbound and outbound network. |
S-1-15-3-3 | privateNetworkClientServer — LAN/private network. |
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. |
Derived capability SIDs #
For application-defined capabilities beyond the well-known set, the SID is derived from the capability name:
| Pattern | Meaning |
|---|---|
S-1-15-3-h1-h2-h3-h4-h5-h6-h7-h8 | A derived capability SID. The eight values come from the SHA-256 of the capability name. The same name always produces the same SID. |
Special placeholder SIDs #
A few SIDs never appear as a real principal but are recognised by the access check.
| SID | Name | How it is used |
|---|---|---|
S-1-3-0 | Creator Owner | Substituted at inheritance time. |
S-1-3-1 | Creator Group | Substituted at inheritance time. |
S-1-3-4 | Owner Rights | Suppresses owner implicit rights when present in a DACL. |
S-1-5-10 | Principal Self | A placeholder for "the principal this object is about". The access check substitutes the caller-supplied self_sid parameter for this SID at evaluation time. Used in directory-style objects where an ACE says "the user can modify their own properties". |
These are the cases where a SID in an ACE does not literally mean "match a token whose user SID is this value". They are special-cased by the access check.
Which ones to use in practice #
The catalog above is comprehensive. In practice, day-to-day administrative work touches a much smaller set:
BUILTIN\AdministratorsandSYSTEMfor default protective DACLs.Authenticated Usersfor "any logged-in user should be able to read this".Everyoneonly when you actually mean "including unauthenticated network peers".Creator Ownerin inheritable ACEs that should follow the owner of a child object.Owner Rightswhen you want to suppress the owner's default grants on a sensitive object.- Specific user/group SIDs allocated by the directory (the
S-1-5-21-...-RIDform) for everyone else.
The remaining well-known SIDs — service SIDs, capability SIDs, integrity labels, PIP labels — show up in specialised contexts that have their own topics in these docs.
Where to go next #
For the attributes a token carries beyond fixed identity — the other input to attribute-based access rules — read Claims on a token.
For the exact numeric values in machine-readable form, see the Well-known SIDs reference.
Claims on a token
Peios / Peios Security Fundamentals / Identity
A claim is a typed key-value attribute that carries information about a principal beyond its identity. The user's department, the machine's compliance status, the time-of-day window for which a session is valid — all of these are claims. They are the raw material that conditional ACEs use to make access decisions about more than just "who is this".
Tokens carry two sets of claims:
- User claims — attributes about the user. Populated at authentication time by authd from the user's directory object.
- Device claims — attributes about the machine. Populated similarly from the machine's directory object.
Both sets are read-only after the token is minted. Adjusting a token's privileges or groups does not touch its claims; only re-authentication does.
What a claim looks like #
A single claim has three things:
- A name — a string, such as
DepartmentorClearance. In conditional expressions the name is qualified by the set it belongs to:@User.Department. - A value — typed (see below). May be a single value or an array of values.
- A set of flags that control how the claim participates in access checks.
Value types #
A claim's value is always one of:
| Type | Notes |
|---|---|
INT64 | A signed 64-bit integer. |
UINT64 | An unsigned 64-bit integer. |
STRING | A UTF-16LE string. |
SID | A binary SID. |
BOOLEAN | TRUE or FALSE. |
OCTET | A raw byte string. |
Mixed-type arrays are not allowed. A claim either holds one STRING, three STRINGs, or one INT64 — never two strings and an integer.
Claim flags #
Each claim carries flags that change how it behaves during access evaluation.
| Flag | Effect |
|---|---|
DISABLED | The claim is invisible to all conditional expressions. Effectively, the attribute does not exist. |
USE_FOR_DENY_ONLY | The claim is invisible to conditions on allow ACEs, but visible to conditions on deny ACEs. This is the conservative downgrade — the claim cannot grant new access but can still trigger denials. |
MANDATORY | The claim cannot be removed or modified without the SeTcbPrivilege. Used for claims that the system itself depends on. |
CASE_SENSITIVE | String and octet comparisons against this claim are case-sensitive. Default is case-insensitive. |
The first two flags (DISABLED, USE_FOR_DENY_ONLY) are the security-relevant ones. They give administrators a way to neutralise a claim (for example, for a logon at a lower trust level) without rewriting the user's directory object.
How claims get onto a token #
flowchart LR
A["User object"] -->|directory| B["authd"]
C["Machine object"] -->|directory| B
B -->|at authentication| D["Token"]
D -->|user_claims| E["@User namespace"]
D -->|device_claims| F["@Device namespace"]
When a user authenticates, authd reads:
- The user's directory object — name, group memberships, any attributes the directory schema defines. Anything the schema marks as a claim attribute is copied into the token as a user claim.
- The machine's directory object — same, but for the device. Anything the schema marks as a device claim is copied into the token as a device claim.
Both sets travel with the token for its entire lifetime.
The directory schema decides what is and is not a claim. A claim that is defined in the schema but absent from a specific user object becomes a missing claim on that user's token — not an empty one, not a default value. Conditional expressions distinguish between the two.
Where claims get used #
Claims do nothing by themselves. They are inputs to conditional ACEs — ACEs whose grant or deny is gated by an expression that references attributes.
A conditional expression refers to a claim by namespace and name:
@User.Department— a claim on the caller's token, user-claims set.@Device.Compliance— a claim on the caller's token, device-claims set.@Resource.Classification— an attribute on the object being accessed.@Local.Time— a per-request attribute supplied by the caller of AccessCheck.
The first two are the token claim sets described above. The last two share the expression syntax but hold different kinds of data:
@Resourcevalues come from resource attributes in the object's SACL, not the caller's token. See Resource attributes.@Localvalues are supplied by the caller as a per-access-check parameter. They never live on a token. They exist for runtime context that does not fit into a directory.
The full grammar of conditional expressions — comparison operators, the three-valued logic (TRUE / FALSE / UNKNOWN), the way missing claims affect the outcome — lives in Conditional ACEs.
Missing claims fail closed #
One behaviour is worth knowing before you get to the conditional ACEs page: a missing claim is not a failure. A conditional expression that references a claim the token does not carry evaluates to UNKNOWN. UNKNOWN on an allow ACE means "skip this ACE"; UNKNOWN on a deny ACE means "treat this as deny". So a missing claim never accidentally grants access — it can only fail closed.
This is why USE_FOR_DENY_ONLY exists. It is the deliberate version of the same idea: take a claim that grants access and demote it to a claim that can only revoke access.
What claims are not #
A few things claims look like at a glance but are not:
- Claims are not groups. A group is a SID in the token's group list; group membership is presence-or-absence. A claim is a typed attribute with a value. The two appear in different parts of the token and are matched by different parts of an ACE.
- Claims are not privileges. Privileges are a fixed system bitmask gating specific operations. Claims are administrator-defined attributes with arbitrary names.
- Claims are not the audit log. Claims describe the principal; the audit log describes what the principal did. The audit-event subject record does include the principal's claims (so audit consumers can write rules over them), but a claim is not itself an event.
Where to go next #
For how the token that carries these claims is built, adjusted, and destroyed, read Tokens.
For the expression grammar that consumes claims — operators, three-valued logic, worked examples — read Conditional ACEs.
Tokens
Peios / Peios Security Fundamentals / Tokens
A token is the runtime carrier of identity in Peios. It is a reference-counted kernel object that holds a user SID, group SIDs, privileges, confinement capabilities, and a handful of other policy fields. Every thread on the system has exactly one effective token at any moment, and every access decision starts by reading that token.
If Identity describes who a principal is, the token is the concrete thing that says "this thread is acting as that principal, right now". Tokens appear throughout these docs because they are the input to AccessCheck for files, registry keys, processes, sockets, and the tokens themselves.
What a token contains #
A token is a structured object with around twenty fields. The full field list is covered in Token types and fields; this section sketches the shape so you have a mental model going into the rest of the topic.
| Group | Fields | What they do |
|---|---|---|
| Identity | user_sid, groups, logon_sid, restricted_sids | Who the token represents. |
| Type and level | token_type, impersonation_level, integrity_level, mandatory_policy | What kind of token this is and how it may be used. |
| Defaults | owner_sid_index, primary_group_index, default_dacl | What the token contributes when new objects are created without an explicit security descriptor. |
| Session and provenance | auth_id, source, token_id, created_at, expiration, origin | Where the token came from and which authentication event it belongs to. |
| Privileges | privilege bitmask (present / enabled / used / removed states) | System-wide rights the token holds. |
| Confinement | confinement_sid, confinement_capabilities, confinement_exempt | Sandboxing constraints, when the token is for a confined application. |
| Claims | user_claims, device_claims, device_groups | Typed attributes for conditional ACE evaluation. |
| Projection | projected_uid, projected_gid, projected_supplementary_gids | Linux-compatibility identity numbers, derived from the rest of the token. |
| Audit policy | audit_policy bitmask | Per-token forced auditing flags. |
| Self SD | security_descriptor | The token is itself an object — this is its own SD, governing who can read or adjust it. |
Most fields are set once when the token is minted and never change. A few — the enabled state of privileges, the enabled state of groups, the default DACL — are adjustable at runtime within strict rules. None of the identity fields (the SIDs themselves) ever change. See Token types and fields for the exact rules.
Tokens and threads #
flowchart LR
A["Thread"] -->|primary token| B["Primary token"]
A -->|impersonation token, when set| C["Impersonation token"]
B -->|read by AccessCheck| D["Kernel"]
C -->|read by AccessCheck when in effect| D
Every thread has a primary token — its baseline identity, inherited at fork and shared with every other thread in the process. While the thread is running normally, the primary token is what AccessCheck reads.
A thread may also temporarily install an impersonation token — a second token that overrides the primary for that one thread until it is reverted. Services use impersonation to act on behalf of a client they are handling: accept the connection, impersonate the client, do the work as them, revert. The primary token survives unchanged; only the one thread sees the impersonated identity, and only until it reverts.
Impersonation has its own topic: Impersonation. For this page it is enough to know that a thread can have two tokens at once, and the kernel reads whichever is "in effect" right now.
Where a token comes from #
A token is always minted by code holding SeCreateTokenPrivilege. In a running system that effectively means authd — the authentication authority — which mints tokens once a principal source has verified who is signing in, and peinit, which mints service tokens during boot before authd is up.
The minting flow is straightforward:
- Authentication succeeds, or a service is being launched.
- The minting component gathers what the token will carry: the identity and memberships the principal source asserted, and the privileges and integrity level this machine's policy grants the principal.
- It calls
kacs_create_tokenwith the resolved values. - The kernel returns a token file descriptor with
TOKEN_ALL_ACCESS. - The minting component installs the token on the target process (the new login session's first process, or the service binary) and closes the fd.
From that point on the token lives in the kernel, referenced by every thread of every process that inherits it. The original fd is gone; the only handle into the token is through the threads carrying it (and any other process that opens it via /proc/<pid>/token or kacs_open_process_token).
No other component creates tokens. There is no API for an ordinary process to fabricate an identity for itself. A process can ask the kernel to duplicate a token it already has, or filter one to a more restricted version, but the original always comes from authd or peinit. This is deliberate: it is what makes the kernel's "where did this identity come from?" question always answerable.
Tokens are objects too #
A token is itself one of the objects KACS protects. It has a security descriptor of its own, and operations on it (querying, adjusting, impersonating, duplicating, installing) all go through AccessCheck against that SD.
| Right | Action it gates |
|---|---|
TOKEN_QUERY | Read token information — fields, groups, privileges, etc. |
TOKEN_DUPLICATE | Create an independent copy via DuplicateToken or FilterToken. |
TOKEN_IMPERSONATE | Install as a thread's impersonation token. |
TOKEN_ASSIGN_PRIMARY | Install as a process's primary token. |
TOKEN_ADJUST_PRIVILEGES | Enable, disable, or permanently remove privileges. |
TOKEN_ADJUST_GROUPS | Enable or disable groups. |
TOKEN_ADJUST_DEFAULT | Change the default DACL, owner index, or primary group index. |
TOKEN_ADJUST_SESSIONID | Change interactive_session_id (additionally requires SeTcbPrivilege). |
By default, a freshly minted token grants the SYSTEM identity and the creating principal full access, and grants the token's own user identity TOKEN_QUERY and the adjustment rights.
This self-protection is what stops one thread from "stealing" another's identity. Even reading the fields of another process's token requires the PROCESS_QUERY_INFORMATION right on the target process and the relevant token rights on the token itself.
The two token rules to remember #
Almost every confusion about tokens reduces to one of two rules.
Rule 1: A thread always has a token. There is no "no identity" state. A thread that has not been given a more specific token is running on whichever token its process inherited — at the very least, the SYSTEM token from boot. Code that runs before authd is up runs as SYSTEM, which has every privilege. Code that runs after authd is up runs as whichever identity authd assigned. There is no third option.
Rule 2: Identity is the token, not the process. A process does not "have an identity" except through the tokens of its threads. Two threads in the same process can be running as different principals at the same instant, because one is impersonating and the other is not. When you debug an "access denied" question, the answer is in the thread's token, not the process's notional identity.
Where to start #
If you want to understand the token field-by-field — what each value means, when it can change, how it is encoded — read Token types and fields.
If you want to know how a token moves through fork, exec, adjustment, and destruction, read Token lifecycle.
If you are interested in the restricted-token model — the sandbox primitive where a token is intersected with a secondary identity list — read Restricted and write-restricted tokens.
If you need to understand UAC-style elevation — the linked Full/Limited token pair that lets one principal have two tokens for the same session — read Elevation and linked tokens.
To work with tokens from a shell — inspect, adjust, duplicate, restrict, impersonate — read The token command.
Token types and fields
Peios / Peios Security Fundamentals / Tokens
Every token in Peios is classified along several axes at once. A given token is either a primary token or an impersonation token. If it is an impersonation token it has an impersonation level. It is either restricted or not. If it is part of an elevation pair it has an elevation type. These are not types in an inheritance sense — they are independent dials, and a token's behaviour is the product of all of them.
This page walks through each axis, then through the token's fields grouped by what they do.
token_type — primary or impersonation #
This is the first and most important classification.
A primary token is the baseline identity of a process. Every process has exactly one. It is inherited across fork — child processes start with a copy of the parent's primary token — and survives exec. When a thread runs without doing anything special, AccessCheck reads its primary token.
An impersonation token is a thread-local override. A thread installs an impersonation token to act as someone else for a span of time, then reverts back to its primary. While the impersonation is in effect, AccessCheck reads the impersonation token instead of the primary. Other threads in the same process are unaffected.
The two types are not interchangeable. Trying to install a primary token as a thread's impersonation, or vice versa, fails. The kernel's kacs_open_self_token and related syscalls return tokens of the correct type for what they were asked to do.
| Use case | Token type |
|---|---|
| A login session's identity | Primary |
| A server thread acting on behalf of a connected client | Impersonation |
| A service running with the identity authd assigned to it | Primary |
| A thread querying its caller's identity for an audit log entry | Impersonation (at the Identification level — see below) |
impersonation_level — how far an identity may travel #
Every token carries an impersonation level. For primary tokens this is set to a conventional value (Anonymous) and ignored. For impersonation tokens it is what bounds what the impersonator may do.
There are four levels, ordered from least to most permissive:
| Level | What a server may do with this token |
|---|---|
| Anonymous | No identity. The token's user SID is the well-known Anonymous SID (S-1-5-7). The server learns nothing about the client. |
| Identification | The server may inspect the client's identity — read their SIDs, groups, integrity level — but may not use the token for any access check. AccessCheck with an Identification-level token returns ACCESS_DENIED immediately, no matter what. |
| Impersonation | The server may act as the client for all local operations. This is the default, and the level most server-to-client code paths assume. |
| Delegation | Same as Impersonation locally, plus the server may forward the client's credentials to a remote machine over Kerberos. KACS only tracks the level; the actual forwarding is authd's job. |
The level is set by the client, on the socket, before connecting. The server cannot raise it. It can only end up with the level the client granted, or lower if other constraints intervene (see Impersonation for the full two-gate model).
The Identification level is a common source of confusion. A frequent bug — in services not written for Peios — is to take a client's token and try to open files as them, finding that every open returns ACCESS_DENIED for reasons the code cannot diagnose. The cause is almost always that the client connected at Identification level and the server is supposed to be inspecting, not acting. The correct fix is at the client end: ask for Impersonation.
restricted — a secondary identity check #
A restricted token carries two SID lists: the normal groups list and a secondary restricted_sids list. During AccessCheck the kernel runs the DACL walk twice and intersects the results — once with the full identity, once with only the restricted SIDs. A right is granted only if both passes grant it.
The effect is to limit a token's identity-based access to what the restricted SIDs would independently receive. The user is still the same user; they just cannot use group memberships or other SIDs that are not in the restricted list to satisfy an ACE.
A write-restricted token is the same idea, but the intersection applies only to write-category rights. Reads and execute come from the normal pass alone. This is the common case: a sandbox that should be able to read system files but only write into a narrow allowed set.
Both variants are covered in detail under Restricted and write-restricted tokens. For this page it is enough to know that "restricted" is a property of the token, set at creation by FilterToken, and that the kernel handles the second pass automatically.
elevation_type — half of a linked pair #
When a principal has both a normal (Limited) token and an elevated (Full) token — the UAC-style pattern — the two tokens are linked at the session level. Each carries an elevation_type saying which half it is:
| Value | Meaning |
|---|---|
| Default | The token is not part of a linked pair. Most tokens. |
| Full | The elevated half of a linked pair. |
| Limited | The non-elevated half of a linked pair. |
A linked pair is established by an explicit call to KACS_IOC_LINK_TOKENS. The elevation type does not change anything about the token's access rights by itself — it is a label that lets the system locate the partner token when the user requests elevation. The mechanics live in Elevation and linked tokens.
Field mutability classes #
Token fields fall into three classes. Knowing which class a field is in tells you whether you can ever change it after the token is minted.
| Class | Meaning | Examples |
|---|---|---|
| Fixed | Set at creation, never changes. Cannot be adjusted at runtime even by SYSTEM. | user_sid, groups[].sid, logon_sid, restricted_sids, token_id, auth_id, created_at, source, confinement_sid, mandatory_policy. |
| Adjustable | Can be changed at runtime through a specific syscall. | Privilege enabled-state (via AdjustPrivileges), group enabled-state (via AdjustGroups), default DACL / owner index / primary group index (via AdjustDefault), security_descriptor (via kacs_set_sd). |
| One-way | Can be tightened but never loosened. | Privilege presence (a privilege can be removed but not re-added), group USE_FOR_DENY_ONLY (can be set but not cleared). |
This split is the source of one rule worth memorising: the identity in a token cannot change. You cannot relabel a token to a different user. You cannot add a group to a token. You can only adjust how existing identity bits are used — enable or disable a privilege, mark a group as deny-only, narrow the default DACL.
If you need a different identity, you need a different token. Either authd mints a new one for you (with a re-authentication), or you DuplicateToken / FilterToken your existing one into a more restricted copy.
Fields by purpose #
The token's fields, grouped by what they do. None of this is a low-level binary layout — that lives in the Kernel ABI reference topic. This is the conceptual field list.
Identity #
The fields that say who the token represents.
| Field | What it is |
|---|---|
user_sid | The primary identity of the token. The principal's SID. |
groups | An array of SID_AND_ATTRIBUTES — every group the principal is a member of, each with its current enabled/disabled state and other flags. |
logon_sid | The session-specific SID (S-1-5-5-X-Y) for the logon session this token belongs to. Also appears in groups with the SE_GROUP_LOGON_ID flag. |
restricted_sids | Secondary identity list for restricted tokens. Empty on unrestricted tokens. |
Type and level #
| Field | What it is |
|---|---|
token_type | Primary or Impersonation. |
impersonation_level | One of the four levels listed above (only meaningful for Impersonation tokens). |
integrity_level | Normally one of Untrusted / Low / Medium / High / System; technically any numeric S-1-16 level (see MIC). |
mandatory_policy | The token's MIC enforcement flags (NO_WRITE_UP, NEW_PROCESS_MIN). |
Defaults #
These fields contribute to the security descriptor of a new object when the creator does not supply one explicitly.
| Field | What it is |
|---|---|
owner_sid_index | Index into [user_sid, groups[0..N-1]] selecting which SID becomes the default owner. |
primary_group_index | Same idea for the default primary group. |
default_dacl | The DACL applied to new objects when no explicit SD is supplied. |
Session and provenance #
| Field | What it is |
|---|---|
token_id | A unique LUID identifying this token instance. |
auth_id | The LUID of the logon session this token belongs to. |
source | An 8-character name plus a LUID identifying the component that minted the token (e.g. authd). |
created_at | Timestamp of original minting. Copied unchanged by DuplicateToken and FilterToken. |
expiration | Expiry timestamp. Set by authd. Informational only in v0.20 — not enforced by AccessCheck. |
origin | The originating logon session for derived tokens (S4U, network logon). |
modified_id | A counter bumped on every adjustment. Used as a cache-invalidation key. |
Privileges #
Privileges live in a 64-bit bitmask with four states per privilege:
- Absent — not on this token at all.
- Present, disabled — on the token but not currently in effect. May be enabled.
- Present, enabled — in effect; AccessCheck will use it.
- Used — a sticky audit bit set when the privilege has been exercised. Never cleared.
See Privileges for the model in detail and the catalog of named privileges.
Confinement #
| Field | What it is |
|---|---|
confinement_sid | If non-null, the token is for a confined application; its identity for access checks is intersected with this SID. |
confinement_capabilities | The list of capability SIDs the confined application has declared. |
confinement_exempt | Escape hatch — if set, confinement is skipped entirely. |
isolation_boundary | Reserved for future use; not enforced in v0.20. |
See Confinement for the sandbox model.
Claims #
| Field | What it is |
|---|---|
user_claims | Typed attributes about the user, populated from the directory at authentication time. Used by @User.* references in conditional ACEs. |
device_claims | Same idea for the machine. Used by @Device.*. |
device_groups | The machine's group memberships, for compound identity. |
See Claims on a token.
Projection #
These fields hold pre-computed Linux UID/GID values for compatibility. They are derived from the token, never the other way around.
| Field | What it is |
|---|---|
projected_uid | The Linux UID the token's identity maps to (or 65534 if unmapped). |
projected_gid | Same for primary GID. |
projected_supplementary_gids | Same for supplementary GIDs. |
See Linux compatibility for what consumes these.
Audit policy #
| Field | What it is |
|---|---|
audit_policy | A bitmask of per-token forced-audit flags (OBJECT_ACCESS_SUCCESS, OBJECT_ACCESS_FAILURE, PRIVILEGE_USE_SUCCESS, PRIVILEGE_USE_FAILURE). |
When a flag is set, AccessCheck emits the corresponding audit event regardless of the object's SACL.
Self SD #
| Field | What it is |
|---|---|
security_descriptor | The token is itself a protected object; this is its own SD. Governs who may query, duplicate, impersonate, install, or adjust the token. |
Other #
| Field | What it is |
|---|---|
interactive_session_id | The interactive session number. Zero for services. Adjustable only with SeTcbPrivilege. |
elevation_type | Default / Full / Limited, for linked-pair membership. |
Where to go next #
For how these fields are set, shared, adjusted, and destroyed over a token's life — mint, fork, exec, impersonation, adjustment — read Token lifecycle.
For the full rules around impersonation tokens and their levels, read Impersonation.
To read these fields off a live token from a shell, read The token command.
Token lifecycle
Peios / Peios Security Fundamentals / Tokens
A token is reference-counted. It comes into existence when a privileged component mints it, is reference-counted up and down as it gets attached to processes and threads, and is destroyed when the last reference drops. Between mint and destruction it moves through a handful of well-defined transitions. None of them change a token's identity — but each affects how the token is reached, how many references it has, or how its mutable fields are set.
Three ways to mint a token #
A token is always created by code holding the right privilege:
| Operation | Effect | Privilege required |
|---|---|---|
kacs_create_token | Mint a token from scratch using a wire-format specification. | SeCreateTokenPrivilege |
| DuplicateToken | Make an independent copy of an existing token. The copy is a new object; modifying it does not affect the source. | TOKEN_DUPLICATE on the source |
| FilterToken | Make a copy of an existing token with privileges removed, groups marked deny-only, or restricted SIDs added. | TOKEN_DUPLICATE on the source |
kacs_create_token is what authd and peinit use to mint genuinely new identities. The other two — DuplicateToken and FilterToken — produce copies derived from a token a process already has. A process can FilterToken its own primary token down to a more restricted version and install the result as the primary token of a child it is about to launch.
In all three cases the kernel:
- Validates the inputs (well-formed SIDs, no duplicate luids, all index references in range, etc.).
- Allocates a new token object with
refcount = 1. - Assigns a fresh
token_idLUID andmodified_id = 0. - Stamps the
created_attimestamp. - Injects the appropriate logon SID into the groups list (callers must not supply it).
- Returns a token file descriptor with
TOKEN_ALL_ACCESS(for create) or the access mask the caller asked for (for duplicate).
Any validation failure results in no token being created — the operation is all-or-nothing.
Attaching to a process #
A token by itself is just an object the kernel holds. It does not yet identify anyone. To take effect, it has to be attached to a process or thread.
There are three attachment paths:
- Inheritance at fork — the child's primary token is the parent's primary token. The token's reference count goes up by one.
- Installation by a privileged caller —
KACS_IOC_INSTALLon a token fd makes the token the calling process's primary. RequiresTOKEN_ASSIGN_PRIMARYon the token andSeAssignPrimaryTokenPrivilegeon the caller. Used by peinit when launching a service: fork, install the service token on the child, exec the binary. - Impersonation on a thread —
KACS_IOC_IMPERSONATEon a token fd makes the token the calling thread's impersonation token. RequiresTOKEN_IMPERSONATE. The process-wide primary token is unchanged.
A given token can be attached in all three ways at once: shared as the primary of N processes, also held as the impersonation token of M threads. Each attachment is a reference.
Fork, exec, and the primary token #
flowchart LR
A["Parent process (primary token T)"] -->|fork| B["Child process (primary token T)"]
B -->|exec| C["Child process (primary token T, possibly with lowered integrity)"]
Fork copies the parent's primary token pointer into the child. Both processes now share the same token object. Adjustments made by the parent are visible to the child instantly — they share the storage. Adjustments made by either process via AdjustPrivileges etc. affect the shared token.
What does not survive fork is the parent's impersonation. A thread that forks while impersonating gets a child whose primary token is the parent's primary (not the impersonation). The child is not impersonating; its first thread is running on the inherited primary.
Thread clone (CLONE_THREAD) is different: the new thread is part of the same process, so it shares the same primary token. Privilege or group adjustments made by any thread are visible to all of them immediately.
Exec keeps the primary token. The new binary runs as the same identity. One subtle exception: if the token's mandatory_policy has NEW_PROCESS_MIN set and the executable carries a lower integrity label than the token, the kernel creates a copy of the token with integrity lowered to match, replaces the primary with that copy, and drops the original reference. This is the mechanism that prevents Medium-integrity code from running at Medium when its image is labelled Low.
Impersonation is always reverted at exec. A thread that execs while impersonating has its impersonation token released before the new program runs. This is enforced — the new binary cannot inherit an impersonation it did not establish.
Impersonation install and revert #
A thread becomes an impersonator by installing an impersonation token. The two ways to do it:
kacs_impersonate_peer(fd)— extract the peer's identity from a connected Unix socket and install it at the appropriate level. The most common path for services accepting client connections.KACS_IOC_IMPERSONATEon a token fd — install a specific token (for transports that do not carry a peer token, or when the server has obtained a token by some other means).
Either operation has the same effect: the thread now has a primary token (unchanged) and an impersonation token (newly installed). AccessCheck reads the impersonation token from this point.
A thread reverts with kacs_revert. It always succeeds. It drops the impersonation reference and restores the original primary as the effective identity.
If a thread that is already impersonating installs a different impersonation token, the kernel silently reverts the old one first and then installs the new one. There is no nesting.
Impersonation tokens come from one of three sources during the install:
- A peer's identity captured at socket connect time.
- A duplicate of an existing token (DuplicateToken to a token type of Impersonation with the desired level).
- A new mint from authd (rare).
The level on an impersonation token is set by the client at connect time, never raised by the server. The full two-gate model — identity gate plus integrity ceiling — is in Impersonation.
Adjustment in place #
Some fields can be changed at runtime. The operations are:
| Syscall / ioctl | What it changes |
|---|---|
| AdjustPrivileges | Enable, disable, or permanently remove privileges. A reset-all sentinel restores defaults. |
| AdjustGroups | Enable or disable group entries. Cannot target mandatory groups, deny-only groups, the logon SID, or the user SID. |
| AdjustDefault | Modify the default DACL, owner index, or primary group index. |
kacs_set_sd on the token fd | Modify the token's own self-SD. Requires WRITE_DAC on the token. |
All adjustments are atomic — invalid input rolls the whole operation back, no partial change. Each successful adjustment bumps modified_id so caches keyed on token state can invalidate.
Because the token storage is shared across all threads of a process, an adjustment made by one thread is visible to all of them immediately. This includes adjustments to the primary token of a process that has just installed it but not yet had every thread converge — the kernel handles the convergence asynchronously, and during the brief window other threads may still see the old token. This window is small enough not to matter in practice but is worth knowing about if you are writing tests.
Permanent privilege removal #
When a privilege is removed rather than just disabled, it is gone from the token permanently. The token's privilege bitmask loses the present and enabled-by-default bits. The used bit stays — its purpose is auditing, and the fact that the privilege was once exercised is information that should not disappear.
Removal is irreversible by design. There is no API to re-add a removed privilege. The only way back is a different token (a fresh authentication, or a duplicate from a source that still has the privilege).
The same applies to SE_GROUP_USE_FOR_DENY_ONLY set on a group: it can be marked, but never cleared. Marking a group deny-only is a one-way trip down the access ladder.
The default DACL #
Every token carries a default DACL. It is the DACL that gets applied to a new object when:
- The creator (for example a
kacs_openwithCREATEdisposition, or a registry-key create) does not supply an explicit SD. - The object's parent has no inheritable ACEs that fully cover it.
In other words, the default DACL is the fallback. It guarantees that any object a token creates has at least some DACL, even when nothing else supplies one.
The default DACL is adjustable via AdjustDefault. A typical default DACL grants the token's user identity and SYSTEM full access and excludes everyone else. Services that want a more permissive or more restrictive default can change it once at startup.
The default owner and primary group also live on the token, as indices into the [user_sid, groups[0..N-1]] array. They tell the kernel which SID to stamp as owner and which to stamp as primary_group when synthesising a new SD. They are adjustable through the same call.
Reference counts and destruction #
Every attachment is a reference. A token is alive as long as any of these are true:
- Any process has it as a primary token.
- Any thread has it as an impersonation token.
- Any process has it open via a token fd.
- It is part of an established linked pair on a still-existing logon session.
When the last reference drops, the kernel destroys the token: frees the storage, releases the reference on the logon session. If the destroyed token's session loses its last token reference too, the session itself is destroyed and a logon-session-destroyed event is emitted via KMES. See Logon sessions.
A token's expiration field has no effect on the lifecycle in v0.20 — it is stored for future use but not enforced. A token lives until its references drop. Session revocation, when needed, is implemented by userspace (authd) walking /proc/*/token, identifying tokens with the offending auth_id, and killing the holding processes. This is documented under Inspecting security state.
Quick reference: which transitions change what #
| Transition | Identity | Privileges | Groups | Integrity | Refcount |
|---|---|---|---|---|---|
| Fork | Same | Same | Same | Same | +1 (shared) |
| CLONE_THREAD | Same | Same | Same | Same | Same object |
| Exec (same integrity) | Same | Same | Same | Same | Same object |
| Exec (NEW_PROCESS_MIN downgrade) | Same | Same | Same | Lowered | New token |
| Impersonation install | Different (impersonation token) | Different | Different | Different | +1 on impersonation token |
| Impersonation revert | Back to primary | Back to primary | Back to primary | Back to primary | −1 on impersonation token |
| DuplicateToken | Same | Same | Same | Same | New token with its own count |
| FilterToken | Same | Subset | Subset (some deny-only, restricted_sids added) | Same | New token |
| AdjustPrivileges | Same | Changed (within rules) | Same | Same | Same object |
| AdjustGroups | Same | Same | Changed (within rules) | Same | Same object |
| KACS_IOC_INSTALL | Same token, different process | — | — | — | New attachment |
| Process exit | — | — | — | — | −1 |
Where to go next #
For what FilterToken's restricted variants actually do at access-check time, read Restricted and write-restricted tokens.
For the session a token belongs to and what happens when the last token of a session is released, read Logon sessions.
To drive these transitions from a shell — duplicate, restrict, adjust, install — read The token command.
Restricted and write-restricted tokens
Peios / Peios Security Fundamentals / Tokens
A restricted token is the kernel's narrow-the-identity primitive. It is a normal token with an extra list of SIDs attached — the restricted_sids list. During AccessCheck the kernel runs the DACL walk twice: once with the token's full identity, once with only the restricted SIDs. A bit is granted only if both passes grant it.
The effect is to put a ceiling on what the token can reach, expressed as "the rights this token would have if it were just those restricted SIDs". The identity (user, groups, privileges) is unchanged — but the kernel will only honour an ACE if the restricted-only view of the token would have honoured it independently.
Restricted tokens are the building block underneath several patterns: service hardening, sandbox processes, anti-malware quarantine, anything that says "this code should have less authority than the user it is running as".
The two-pass model #
flowchart TD
A["AccessCheck"] -->|with full identity| B["Normal pass (user_sid + groups + ...)"]
A -->|with restricted SIDs only| C["Restricted pass (restricted_sids only)"]
B -->|granted_normal| D["Intersect"]
C -->|granted_restricted| D
D -->|granted = normal ∩ restricted| E["Final granted mask"]
When KACS evaluates AccessCheck for a restricted token, it runs through the same pipeline twice:
- Normal pass. The DACL walk uses the token's
user_sid, all enabled groups, and all the usual rules. Produces agranted_normalmask. - Restricted pass. The DACL walk runs again, but the only SIDs considered for matching are those in
restricted_sids. Theuser_siddoes not match. None of the regulargroupsmatch. Only the entries inrestricted_sidscount. Produces agranted_restrictedmask. - Intersection. The final granted mask is
granted_normal & granted_restricted.
Both passes must agree to grant a right. If the normal pass would grant FILE_WRITE_DATA but the restricted SIDs do not appear in any allow ACE for that right, the bit is dropped.
The restricted SIDs are usually narrow on purpose. A common pattern is to put a single capability SID (for example internetClient) in the restricted list — the token now has authority only on objects whose DACLs explicitly grant access to that capability.
What is not affected #
A few things are explicitly not narrowed by the restricted pass:
- Privileges. Privilege-granted access (SeBackup, SeRestore, SeTakeOwnership, SeSecurity) is restored after the intersection. A restricted token with SeBackup can still read any file the privilege would have granted.
- The default DACL on new objects. A restricted token still creates new objects with its default DACL, derived from its full identity.
- Reading the token's own state. Querying a restricted token does not require both passes; the token's self-SD governs that as normal.
- MIC. Mandatory integrity is evaluated before the DACL walks. A restricted token cannot use the restricted-SID trick to escape integrity rules.
Privileges being orthogonal to the restricted pass is the most important of these. The restricted-token model narrows identity-based access, not capability-based access. If you want to drop privileges too, that is a separate operation — see "Creation" below.
Write-restricted: the common case #
A write-restricted token narrows only the write-category rights. Reads and execute come from the normal pass alone; only write-mapped bits go through the intersection.
The motivation: it is rare to want a sandbox that cannot read anything in the normal world. Sandboxed code usually needs to load shared libraries, read configuration, perhaps look up its own metadata. What it must not do is write — into the user's home directory, into system paths, into any file outside the small set the sandbox explicitly allows.
In a write-restricted token:
| Right category | Where it comes from |
|---|---|
| Read (FILE_READ_DATA, FILE_LIST_DIRECTORY, etc.) | Normal pass only. |
| Execute (FILE_EXECUTE, FILE_TRAVERSE) | Normal pass only. |
| Write (FILE_WRITE_DATA, FILE_APPEND_DATA, WRITE_DAC, etc.) | Intersection of both passes. |
The "category" is determined by which generic right the bit maps to. FILE_WRITE_EA is in the write category; FILE_READ_EA is in the read category; and so on. The generic mapping for each object type defines the partition.
There is a quirk worth noting. When a token is write-restricted, the kernel also sets a user_deny_only flag on the token internally. This causes the token's own user_sid to match only deny ACEs in any pass, never allow ACEs. The motivation is to prevent a token from getting write access on an object simply because its user SID matches an allow ACE — the write-restricted intersection would otherwise be too easy to bypass by writing an ACE that names the user directly. With user_deny_only set, the user SID can still trigger denials but cannot grant.
This is a subtle interaction; most code that uses write-restricted tokens does not need to think about it, but if you are debugging an access denial on a write-restricted token and the user appears in the DACL with an allow ACE, the explanation is here.
Creation #
There is one path to creating a restricted token: KACS_IOC_RESTRICT on a token fd — the ioctl behind the FilterToken operation named elsewhere in this topic. The operation takes:
- A list of privilege LUIDs to remove from the new token.
- A list of group indices to mark
SE_GROUP_USE_FOR_DENY_ONLYin the new token (set once, irreversible). - A list of restricting SIDs to put in the new token's
restricted_sids. - An optional flag enabling write-restricted mode (which also sets
user_deny_only).
The result is a new token fd. The source token is unchanged.
A typical sandbox launcher does something like this:
- Open its own primary token.
- Call
KACS_IOC_RESTRICTto produce a restricted variant: remove every privilege exceptSeChangeNotifyPrivilege, mark unsafe groups deny-only, add the sandbox's allowed capability SIDs as the restricted SIDs, set the write-restricted flag. - Fork.
KACS_IOC_INSTALLthe restricted token on the child.- Exec the sandboxed binary.
The child now runs as the same user, but the user's group memberships are mostly invisible, the privileges are gone, and writes are confined to objects whose DACLs explicitly allow the sandbox's restricted SIDs.
Restricted tokens vs confinement #
Two things in Peios narrow what a token can reach: the restricted-token model on this page, and confinement (covered in Confinement). They look similar at a glance, but they exist for different audiences.
Restricted tokens are a tool for code. A program — a service, a sandbox launcher, an anti-malware engine — uses FilterToken or KACS_IOC_RESTRICT to narrow a token it already has, then runs sensitive work on the result. The decision is made in code, by the program itself, before it hands the restricted token to the constrained operation. Nothing outside the program needs to know about it, and nothing outside the program enforces it — the program is choosing to give itself less authority.
Confinement is a tool for policy. A sysadmin — or a service definition the sysadmin has chosen to deploy — declares that some component runs as a confined application with a specific confinement SID and an enumerated set of capabilities. The kernel enforces that policy whether or not the confined code is aware of it. Confinement is an administrative decision applied from outside the program, and the program cannot opt out of it.
That difference in audience is the reason behind the technical differences:
| Restricted | Confinement | |
|---|---|---|
| Who decides | The program itself, in code | Administrative policy, applied from outside |
| Typical caller | A sandbox launcher, a hardened service, an anti-malware engine | A service manager applying a service definition; a container runtime |
| Storage on the token | restricted_sids list | confinement_sid + confinement_capabilities |
| Where it fires in AccessCheck | Inside the DACL walk, identity-based intersection | After the DACL walk and privileges, absolute intersection |
| Bypassable with a privilege? | Yes — privilege-granted bits survive | No — confinement is absolute |
| Owner implicit rights still apply? | Yes | No |
| Write-only variant available? | Yes (write-restricted) | No |
The technical asymmetry follows from the audience. A program restricting itself is trusting itself to use the primitive correctly — it can drop privileges if it wants to, leave them in if it doesn't, choose what its restricted SID set should be. Confinement is enforced against the code, so it has to be a harder line: privilege exercise and owner implicit rights are exactly the kinds of escape routes a confined application would otherwise reach for.
The two can be combined. A service that runs under a confinement policy and additionally restricts its own internal worker threads sets both. The kernel applies each layer; the final granted mask is the intersection of all of them.
Practical patterns #
A few patterns worth recognising:
- Drop privileges only. Sometimes you want to remove dangerous privileges without restricting identity at all.
KACS_IOC_RESTRICTwith a privilege removal list, an empty deny-only list, and an empty restricted_sids list does this — the result is a token with reduced privileges and no restricted-SID intersection. - Write-restricted with the deny-only user trick. Set the write-restricted flag, leave
restricted_sidscontaining only what you want the sandbox to be able to write to. The token's user SID can still match deny ACEs (so user-targeted denials still work) but cannot match allow ACEs on writes. - Capability-style sandbox. Put one or more capability SIDs (well-known or derived) in
restricted_sids. The sandbox can then reach only objects whose DACLs explicitly grant access to those capabilities, plus whatever its user identity grants in the normal pass. - Anti-malware quarantine. Restrict to a narrow set of well-known SIDs (Everyone, Authenticated Users) and remove all privileges. The result is a token that can reach widely-shared resources but cannot exercise any system-level rights.
All of these are FilterToken / KACS_IOC_RESTRICT applied to the appropriate source token, with different inputs. The kernel does not distinguish between them; they are just patterns of use.
Where to go next #
For the linked Full/Limited token pair — the other derived-token pattern this topic covers — read Elevation and linked tokens.
For the policy-driven counterpart to restricted tokens, read Confinement.
To build a restricted token from a shell, read The token command.
Elevation and linked tokens
Peios / Peios Security Fundamentals / Tokens
A linked token pair is two tokens for the same principal, one elevated (Full) and one not (Limited), associated with each other through their shared logon session. The point of the pair is to give a user a default identity that is not fully privileged, while keeping a second identity available for explicit elevation when the user actually wants to do administrative work.
If you have used UAC on a Windows desktop, this is the same model. The Limited token is what runs the user's shell and most of their software. The Full token is what runs the action they have just been prompted to authorise. The kernel does not decide when to switch — that is a user-space decision, prompted by some authority broker — but the kernel is responsible for keeping the pair linked, locating the partner on demand, and enforcing the rules around who is allowed to see what.
The model #
flowchart LR
A["Logon session"] -->|associates| B["Full token (elevation_type = Full)"]
A -->|associates| C["Limited token (elevation_type = Limited)"]
B <-->|partner| C
Both tokens share the same logon session (auth_id). They have the same user SID, the same logon SID, the same created_at. What differs:
- The Full token has whatever privileges and group memberships the principal is entitled to when running elevated — typically including BUILTIN\Administrators, SeBackup, SeRestore, etc.
- The Limited token is a filtered version — privileges removed, sensitive groups marked
USE_FOR_DENY_ONLY. It is the version the user runs in by default.
Each carries an elevation_type:
| Value | Meaning |
|---|---|
| Default | Token is not part of a linked pair. The vast majority of tokens. |
| Full | The elevated half of a pair. |
| Limited | The non-elevated half of a pair. |
A token's elevation_type is set when it joins a pair, never cleared. If the session is destroyed, the pair linkage is removed but the individual elevation_type values stay until the token objects themselves are freed.
Establishing a pair #
A pair is created by an authority broker — almost always authd, occasionally peinit — using:
KACS_IOC_LINK_TOKENS(elevated_fd, filtered_fd, session_id)
The kernel requires:
SeTcbPrivilegeon the caller.TOKEN_DUPLICATEon both token fds.- Neither token already linked.
- Both tokens part of the same session (
session_id). - The two tokens not the same token.
When the call succeeds, the kernel:
- Records the pair on the session.
- Sets the elevated token's
elevation_type = Full. - Sets the filtered token's
elevation_type = Limited.
Both tokens continue to be valid as independent tokens. The pair linkage is additional state on the session, not a property that bundles the two into one object.
A typical flow during user login — none of which authd does yet, per the note at the top of this page:
- authd authenticates the user.
- authd creates the user's session.
- authd mints the Full token with all the user's entitled privileges and groups.
- authd FilterTokens the Full token down to the Limited version — privileges removed, admin groups deny-only.
- authd calls
KACS_IOC_LINK_TOKENSto link the two. - authd installs the Limited token as the primary of the user's first process.
- The Full token is kept available (via session state or an authority broker process) for later elevation requests.
The user's shell, file manager, web browser, and most of their applications now run on the Limited token. Whenever the user does something administrative, the authority broker — after whatever consent step is appropriate — fetches the Full token via KACS_IOC_GET_LINKED_TOKEN and installs it on the new process.
Looking up the partner #
KACS_IOC_GET_LINKED_TOKEN(token_fd) -> partner_fd
Given a token that is part of a pair, this ioctl returns a handle to the partner. The semantics depend on who is asking:
- With
SeTcbPrivilege, the caller gets a full handle on the partner token —TOKEN_ALL_ACCESS, the actual token object. This is what authority brokers use to perform an elevation: fetch the Full token's fd, install it on a child process. - Without
SeTcbPrivilege, the caller gets a degraded handle — a freshly duplicated Identification-level clone of the partner, opened only withTOKEN_QUERY. The clone is enough to inspect the partner's identity ("am I currently the Limited token? what would the Full version contain?") but cannot be used for any access check and cannot be installed.
The asymmetry is deliberate. Anyone who can prove they hold one half of a pair is allowed to learn something about the other half — that is useful for diagnostics and for user-facing tools that want to display "you are running as Limited; admin rights are available". But actually wielding the elevated identity requires SeTcbPrivilege, the privilege held only by the authority broker that is supposed to gate elevation.
KACS_IOC_GET_LINKED_TOKEN fails with -ENOENT on a token whose elevation_type is Default, or on a token whose pair was destroyed when its session was destroyed.
What the link does not do #
A few clarifications on what linkage does not change:
- It does not unify the two tokens. They remain independent objects. Adjustments to one have no effect on the other. Destroying one does not destroy the other.
- It does not change access decisions. AccessCheck reads whichever token is currently in effect on the thread. The fact that a token has a partner is invisible to the access check — only the broker that calls
KACS_IOC_GET_LINKED_TOKENsees the pair. - It does not require either token to be installed. A pair can exist on a session that has not yet attached either of its tokens to any process. (Unusual but valid.)
- It does not change
auth_id. Both tokens still report the same session ID. The session is the level at which they are paired.
Teardown #
A pair is dissolved when the underlying logon session is destroyed. At that point:
- The pair association is removed from the session.
- Subsequent calls to
KACS_IOC_GET_LINKED_TOKENon either token return-ENOENT. - The individual tokens themselves continue to exist as long as their reference counts hold. Their
elevation_typevalues are unchanged but no longer meaningful.
A session is destroyed when its last token reference drops (see Logon sessions). For a linked pair, that means: both tokens must lose all their attachments — every process running on them must exit, every fd open on them must be closed, every impersonation token derived from them must be reverted.
For the Limited token this happens naturally when the user logs out. For the Full token, the authority broker is responsible for releasing it when the session ends — typically by holding it in a process that exits when the user logs out.
Common patterns #
Default-Limited login. Every interactive login establishes a pair where the user's shell runs as Limited. The Full token is held by the authority broker.
Elevation on demand. When the user invokes an administrative action (a control panel, a sudo-equivalent), the broker prompts for consent, then uses KACS_IOC_GET_LINKED_TOKEN to fetch the Full token and KACS_IOC_INSTALL to install it on the privileged child process.
Service accounts (no pair). Services do not generally need elevation; their tokens are unpaired and elevation_type = Default. A service that needs occasional elevated work is a different pattern — usually IPC to an already-elevated service rather than a linked pair within the same service.
Diagnostics ("am I elevated?"). A process can call KACS_IOC_GET_LINKED_TOKEN on its own primary token (which it always has at least TOKEN_QUERY on). Without SeTcb it gets an Identification-level clone — enough to read elevation_type and groups on the partner and display "elevated rights available". The clone itself cannot be used for anything but inspection.
Where to go next #
For the session object that holds the pair together — and what happens to the pair when it dies — read Logon sessions.
To inspect a token's elevation type and its partner from a shell, read The token command.
The token command
Peios / Peios Security Fundamentals / Tokens
token is the command-line tool for working with tokens directly. It reads a token's contents, adjusts it, produces derived tokens, and drives impersonation — the low-level operations this topic describes, exposed at a shell.
token subcommand [target] [arguments]
$ token # one-line summary of your own token
$ token show --all # every field of your own token
$ token privs --pid 4821 # the privileges on process 4821's token
token is a direct, debug-level tool. Day to day you do not inspect tokens by hand — the system does. token is for diagnosing an access problem, for understanding what identity a process is really running under, and for building and testing identity setups. Run with no subcommand, it prints a one-line summary of your own token.
Choosing which token #
Almost every subcommand operates on a token, and these flags choose which one. With none, the target is your own.
| Flag | Target |
|---|---|
--self | Your own token. The default. |
--real | Your primary token specifically, rather than the effective one — relevant when your thread is impersonating. |
--pid PID | The primary token of process PID. |
--tid TID | The impersonation token of thread TID (used with --pid). |
--peer SOCK_FD | The peer's captured token on a connected socket — see Peer tokens. |
Reading another process's token is itself access-controlled: it succeeds only with the right authority over that process.
Inspecting a token #
show #
token show prints a token's contents. It is the default — bare token is token show --short.
| Flag | Effect |
|---|---|
--short | A one-line summary. |
--all | Every query class — the fullest dump. |
Field accessors #
Each of these prints one part of a token, for when you want just that piece:
| Subcommand | Prints |
|---|---|
user | The user SID — who the token is. |
owner | The default owner SID. |
group | The primary group SID. |
groups | The group list. |
privs | The privileges, with their enabled state. |
caps | The capabilities. |
claims | The user and device claims. |
integrity | The integrity level. |
logon | The logon type and logon SID. |
source | What minted the token. |
origin | The originating session for a derived token. |
stats | Token statistics — IDs, timestamps, the modification counter. |
default-dacl | The token's default DACL. |
query #
token query CLASS performs a raw read of a single named token-info class and prints the result as JSON — the lowest-level inspection route, for tooling.
Changing a token #
adjust #
token adjust mutates a token in place:
| Form | Changes |
|---|---|
adjust privs NAME=STATE … | Enable, disable, or remove privileges. STATE is enabled, disabled, or removed. |
adjust groups IDX=STATE … | Enable or disable groups by their list index. |
adjust default --dacl SDDL | Replace the token's default DACL. Also --owner-idx / --group-idx. |
adjust session ID | Replace the token's session id. |
restrict #
token restrict produces a restricted token — a more limited variant of a token.
| Flag | Effect |
|---|---|
--drop-privs MASK|NAMES | Privileges to drop. |
--deny IDX,… | Group indices to mark deny-only. |
--restrict SID,… | The restricting SIDs to apply. |
duplicate #
token duplicate (alias dup) copies a token, optionally changing its --type (primary or impersonation), its impersonation --level, or its --access mask.
link and linked #
token link joins two tokens as an elevation pair — a full token and its filtered counterpart — given their file descriptors and a session id. token linked shows a token's elevation-linked counterpart, if it has one. See Elevation.
Impersonation #
| Subcommand | Effect |
|---|---|
impersonate | Begin impersonating the target token on the calling thread. With a trailing -- command …, run that command under the impersonating token. |
revert | Drop any active impersonation on the calling thread. |
See Impersonation for the model these drive.
Creating tokens #
| Subcommand | Effect |
|---|---|
create SPEC | Create a token from a binary token-spec (SPEC is a file, or - for standard input). |
install SPEC | Create a token from a spec and install it as the caller's primary token. |
Creating and installing tokens is a privileged operation, reserved for the components that legitimately mint identity.
Output options #
| Flag | Effect |
|---|---|
--raw | Render SIDs in raw S-1-… form only. |
--label | Render SIDs as their labels where known, falling back to raw. |
--json | Emit JSON instead of human-readable output. |
token and the inspection surfaces #
token is the convenient front-end. Underneath, it reads the same kernel surfaces and rules described in Inspecting tokens — that page covers the query mechanism, the access rules for reading another process's token, and what cannot be inspected.
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded. |
1 | A usage error. |
| non-zero | The operation failed — no such target, an access denial, or a bad spec. |
Logon sessions
Peios / Peios Security Fundamentals / Logon Sessions
A logon session is the kernel object that records one authentication event. It exists from the moment a principal successfully signs in to the moment every token derived from that sign-in is released. Every token references exactly one session through its auth_id field, and the session ties together everything that came out of that sign-in: the primary tokens, the impersonation tokens derived from them, the linked Full/Limited pair when one is established.
Sessions exist to answer two questions cleanly:
- "Did all of these tokens come from the same sign-in?"
- "When this principal logs out, what should we tear down?"
The first matters for audit and for revocation. The second matters because logging out has to do more than just close a shell — it has to release Kerberos tickets, drop linked-pair associations, fire the right events. Sessions are the unit at which those teardowns happen.
What a session contains #
A logon session is a small kernel object — fewer fields than a token, no privileges or groups of its own. Its job is to identify the event, not the principal.
| Field | What it is |
|---|---|
session_id | A unique LUID identifying the session. Same as the auth_id on every token belonging to the session. |
logon_type | What kind of sign-in produced this session — Interactive, Network, Service, Batch, etc. See Logon types. |
user_sid | The principal who signed in. The same SID appears in user_sid on every token of the session. |
auth_package | A string identifying the authentication mechanism (e.g. "Kerberos", "NTLM", "local"). Informational; not used by AccessCheck. |
created_at | Timestamp of the sign-in. |
logon_sid | A per-session SID derived from the session_id. See below. |
The session is the unit of identity provenance. If you want to answer "where did this token come from?", the path is: token → auth_id → session → logon_type, auth_package, created_at, user_sid. Everything an audit log needs to reconstruct a sign-in is reachable from the session.
The logon SID #
Every session has its own SID, called the logon SID:
S-1-5-5-X-Y
Where X and Y are the high and low 32 bits of the session's LUID. The logon SID is unique to the session — two different logins by the same user produce two different logon SIDs, because they have different session IDs.
Every token of the session carries its logon SID in two places: the dedicated logon_sid field, and as an entry in the groups list with the SE_GROUP_LOGON_ID flag set. Both views point at the same value. The flag exists so the access check can identify the logon SID without needing to know its specific value.
The logon SID is what lets you write an ACE that targets "the specific sign-in that did this" rather than "this user". Common uses:
- Per-session temporary objects. Grant access to the logon SID only. When the session ends, the SID becomes unreachable (no future token will ever carry it), so the object is effectively cleaned up at logout from an access-control perspective.
- Service-to-user isolation in shared sessions. Two services running for the same user but in different sessions can use the logon SID to keep their per-session state apart even though the user SIDs are identical.
- Audit anchoring. An audit event records the logon SID alongside the user SID. The two together let an investigator distinguish "this user did this" from "this specific sign-in did this".
The logon SID cannot be disabled (SE_GROUP_LOGON_ID is a mandatory-equivalent flag), and it cannot be removed from the token. It is intrinsic to the token.
Sessions and tokens #
A session can have many tokens. Every primary token of a process belonging to the session, every impersonation token derived from one of those primaries, every duplicated or filtered copy that shares the same auth_id — all of them are tokens of that session.
flowchart LR
A["Logon session, auth_id=42"] --> B["Primary token (user's shell)"]
A --> C["Primary token (child process)"]
A --> D["Impersonation token (service handling this user)"]
A --> E["Filtered token (restricted sandbox of B)"]
A --> F["Full token (linked pair)"]
A --> G["Limited token (linked pair)"]
The kernel keeps a reference count of how many tokens belong to a session. When the count drops to zero — every token of the session has been released — the session itself is destroyed and a logon-session-destroyed event is emitted.
The implication is that destroying a session is not a primitive operation. There is no kacs_destroy_session syscall. You destroy a session by ensuring every token belonging to it is released, which usually means killing every process running on those tokens. Session revocation — what authd does when an administrator forces a logout — is implemented this way in user space.
Bootstrap sessions #
Two sessions exist before authd is up, created by the kernel during boot:
| Session | ID | Purpose |
|---|---|---|
| SYSTEM session | 0 | The session of the SYSTEM token, attached to init and inherited by every process until authd assigns real tokens. |
| Anonymous session | 998 | The session of the Anonymous token, used as the user SID for Anonymous-level impersonation. |
Both are created by direct kernel initialisation — they do not go through kacs_create_session. They are also never destroyed during a running system's lifetime: the SYSTEM token always exists somewhere, and the Anonymous token is a singleton.
You will see these IDs in audit logs and /sys/kernel/security/kacs/sessions listings. They are not bugs.
Sessions are not durable #
A session exists in the kernel only while it has active tokens. There is no on-disk session table. The session ID space is not stable across boots — after a reboot, the same user signing in again gets a different session ID, and a new logon SID.
This matters in two practical ways:
- You cannot reference a session across a reboot. An ACL entry that targets the logon SID of "the previous boot's session 5" is meaningless after the next boot. The SID format will look syntactically valid, but no token will ever match it.
- Per-session state should be in memory, not on disk. Anything intended to survive a single sign-in should be keyed on the user SID, not the logon SID.
Where to start #
If you want the catalog of logon types — what Interactive, Network, Service, Batch, NewCredentials, and the rest each mean — read Logon types.
If you want the creation, destruction, and revocation mechanics — including the logon-session-destroyed event and what authd does for forced logout — read Session lifecycle.
If you want to see which sessions are currently active on a running system, read Inspecting tokens, sessions, and processes.
To work with sessions from a shell — list them, see their processes, create and destroy them — read The logonse command.
Logon types
Peios / Peios Security Fundamentals / Logon Sessions
Every logon session carries a logon_type — a single number, set by authd at session creation, that classifies the nature of the sign-in. The type is informational from KACS's point of view: AccessCheck does not branch on it. But it is recorded in every audit event that references the session, and it is what audit consumers and SIEM tools use to distinguish "the user logged in at the console" from "the user logged in over the network" from "a service started under this account".
There are six types in v0.20.
The six types #
The numeric values are also catalogued in Other constants.
| Value | Name | What it means |
|---|---|---|
| 2 | Interactive | The user signed in at the console, an SSH session, or some other interactive channel. The most common type for human users. |
| 3 | Network | The user authenticated to access network resources without an interactive sign-in. Typical for SMB, RPC, federated services. |
| 4 | Batch | A scheduled job. The principal is logged in to run a task at a specific time, not by a user actively present. |
| 5 | Service | A service started under a specific principal. Used for the long-lived service-account model. |
| 8 | NetworkCleartext | A network logon where the credential was transmitted in cleartext over the wire. The session is otherwise a Network session; the type distinction exists for audit. |
| 9 | NewCredentials | A session created to use different credentials when reaching out to remote resources, while keeping the local identity unchanged. The local thread continues to act as its primary token; outbound network requests carry the alternative credentials. |
Values 1, 6, 7, and 10 onward are reserved and not used in v0.20.
When each type is used #
Interactive #
The default for any sign-in where a human is at the keyboard. Console logins, SSH connections, terminal services, the lock-screen unlock — all Interactive.
You will see Interactive in audit logs whenever a real user begins a session. If your audit policy distinguishes "human did something" from "automation did something", the Interactive type is the marker for the human side.
Network #
Used when a principal authenticates only to access a network resource — for example, a remote user mounting a file share. The session exists on the server, the user's identity is established there, but the user has no interactive presence on the machine. The session's tokens are typically short-lived (one connection's worth of work).
A Network session is what an inbound SMB connection produces, what an authenticated RPC call produces, what a federated service request produces. It is also what a host typically sees when a user reaches a service on it that is acting on the user's behalf.
Batch #
Scheduled tasks. The principal is "logged in" only in the sense that the system mints a token for the user so the task can run as them. There is no human presence. Once the task finishes, the session ends.
Batch sessions are useful for auditing scheduled work — distinguishing "the user typed this command" from "the user's scheduled task did this command".
Service #
The session under which a service runs. The service account signs in once at service start; the session lives for as long as the service runs. Most services on a Peios machine are Service-type sessions.
This is the type you will see most often in the session listing at /sys/kernel/security/kacs/sessions on a system without active interactive users — every running service contributes one Service-type session.
NetworkCleartext #
Used when the authentication protocol exposed the credential in cleartext on the wire. The semantic effect is identical to Network — it is a network sign-in producing a Network-style session — but the type difference exists so audit policy can flag the cleartext exposure.
You should rarely see this in normal operation. Its presence in an audit log is worth attention; it usually indicates that an older or misconfigured protocol is in use.
NewCredentials #
The unusual one. A NewCredentials session does not replace the calling thread's identity. Instead, it represents "I want to keep being myself locally but use these other credentials for outbound network calls". The thread's effective token retains the local user's SIDs and privileges for everything KACS evaluates locally, but any outbound credential-using request carries the alternative principal.
The pattern matters in environments where a user has local rights on one machine and different rights on another, and wants to keep both available without switching sessions.
Where the type appears #
The logon type is stamped in three places you will care about:
- In the session itself, retrievable through
/sys/kernel/security/kacs/sessionsand the session inspection APIs. - In every token's session — via the token's
auth_idfield, which points to the session, which holds the type. - In audit events — every audit event that includes a subject also implicitly identifies the subject's session, and audit consumers can use that to retrieve the logon type for the event.
The type is not part of any token's SID list. There is no "Interactive" SID that gets added to a token's groups based on the logon type. The well-known SIDs like S-1-5-4 (Interactive) and S-1-5-2 (Network) are added to a token's groups by authd based on the logon type, but they are independent token attributes from that point on. A token with the Interactive group SID has it because authd put it there; the session's logon_type is the input that drove that decision.
This independence is intentional. A token can in principle carry the Interactive group without belonging to an Interactive-type session (rare, but legal). Audit and ACL evaluation use the group SID; provenance uses the session's type.
What the type does not affect #
AccessCheck does not read the logon type. The DACL walk, the MIC check, the PIP check, the restricted/confinement passes — none of them branch on logon_type.
If you want different access for "Interactive users" vs "Network users", you write ACEs that reference the well-known group SIDs (S-1-5-4, S-1-5-2, etc.). Those SIDs end up in the token because of the logon type, but the access check matches on the SID, not the type.
The type matters at the seams: when authd is making decisions about which groups to add to a token, when the audit subsystem is recording provenance, when a SIEM correlates events across sessions. Inside the access check itself, the type is invisible.
Where to go next #
For how a session is created, destroyed, and forcibly revoked, read Session lifecycle.
For the well-known group SIDs (S-1-5-4 Interactive, S-1-5-2 Network, and the rest) that authd derives from the logon type, read Well-known principals.
To see the logon type of each active session from a shell, read The logonse command.
Session lifecycle
Peios / Peios Security Fundamentals / Logon Sessions
A session's life is bracketed by two kernel events: a successful kacs_create_session call that brings it into existence, and the implicit drop of its last token reference that destroys it. There is nothing in between — no resize, no rename, no kernel-side timeout. Sessions are simple objects whose lifecycle is driven entirely by the tokens attached to them.
This page covers the three phases that matter: creation, destruction, and revocation (the userspace pattern for forcing a session to end before the user logs out voluntarily).
Creation #
A session is created by kacs_create_session. The call requires SeTcbPrivilege, so in practice the only callers are authd (every interactive and network sign-in) and peinit (services launched during boot before authd is available).
The call takes a wire-format specification with three fields:
| Field | Meaning |
|---|---|
logon_type | The type — Interactive, Network, Service, Batch, etc. See Logon types. |
auth_package | A string identifying the authentication mechanism (informational). |
user_sid | The principal who signed in. |
The kernel:
- Validates the inputs (well-formed SID, recognised logon type, sane string lengths).
- Allocates a session object with a fresh
session_idLUID. - Stamps
created_at. - Derives the logon SID
S-1-5-5-X-Yfrom the session ID. - Initialises the session's token reference count at zero.
- Returns the new session ID to the caller.
The session now exists but has no tokens. It is in a transient state: any subsequent kacs_create_token call that references this session_id in its auth_id field bumps the count, and the session is "live". If no token is ever created against the session — vanishingly rare — the session stays at refcount zero and is reaped after a brief grace period.
The grace period is what avoids a race: authd's flow is "create session, then mint primary token referencing it". Between those two calls the session has no tokens, and a strict "destroy when refcount hits zero" rule would tear it down before authd's second call. The kernel solves this by only triggering destruction on a refcount that transitions from positive to zero, not on a refcount that has been zero since creation. After a successful first attachment, normal destruction rules apply.
The boot sessions #
Two sessions exist without ever passing through kacs_create_session:
| Session | ID | Created by |
|---|---|---|
| SYSTEM session | 0 | Direct kernel init |
| Anonymous session | 998 | Direct kernel init |
Both are constructed in early boot, before any process exists. The SYSTEM session is attached to the kernel's bootstrap SYSTEM token, which init inherits and which propagates through every process until authd assigns real tokens. The Anonymous session backs the well-known Anonymous token used by Anonymous-level impersonation.
Neither is ever destroyed during the running system's lifetime. They are reference-counted like any other session, but their references never drop to zero — the SYSTEM token always has at least one attachment somewhere, and the Anonymous token is a kernel-internal singleton.
Destruction #
A session is destroyed when its token reference count, having been positive, drops to zero. The kernel:
- Removes the session from the session table.
- Tears down any linked-pair association it was holding (see Elevation and linked tokens).
- Emits a
logon-session-destroyedevent through KMES.
The event carries enough information for consumers to clean up downstream state:
| Field | What it is |
|---|---|
session_id | The destroyed session's ID. |
user_sid | The principal. |
logon_type | The type. |
auth_package | The auth package string. |
created_at | The session's creation timestamp. |
The most important consumer of this event is authd itself. authd subscribes to logon-session-destroyed because it needs to release session-scoped state of its own: Kerberos tickets, cached directory data, any per-session credentials it has been holding. Without the subscription, authd would have no way to know that a session it created is gone.
Other consumers (audit pipelines, accounting tools, session-aware services) may also subscribe. The event is fire-and-forget — there is no acknowledgement, no retry, no replay. A consumer that misses an event misses it.
What ends a session #
A session ends only when every reference to it drops. The references are:
- Every primary token attached to a process with
auth_id = session_id. - Every impersonation token currently installed on any thread.
- Every token fd open on a process's behalf.
- The linked-pair association, while it exists.
Practically, this means a session ends when:
- Every process running on a token of the session has exited.
- Every thread that was impersonating a token of the session has reverted or exited.
- Every fd held on a token of the session has been closed.
- The linked pair, if any, has been dissolved.
The fourth condition is satisfied automatically when the session reaches refcount zero — the kernel dissolves the pair as part of destruction. The first three are user-space's responsibility.
For an ordinary logout, this happens naturally: the user's shell exits, child processes exit, the authority broker process closes its hold on the Full token. Once all of those happen, the kernel sees refcount zero, fires the event, frees the session.
Revocation: there is no kernel call #
There is no syscall to forcibly end a session. No kacs_destroy_session, no kacs_kill_session. The session model is reference-counted, and the only way to end one is to ensure every reference drops. (The one destroy syscall that exists, kacs_destroy_empty_session, is a rollback primitive for empty sessions only — it refuses with -EBUSY any session that still has live tokens. This is what logonse destroy wraps.)
Forced logout — an administrator deciding that user X should not be signed in any more — is therefore a userspace operation. The pattern, implemented by authd:
- Walk
/proc/*/tokento find tokens whoseauth_idmatches the target session. - For each matching token, identify the process holding it.
- Send the appropriate signal to terminate the process (typically SIGTERM with a grace period, then SIGKILL if needed).
- Continue until no processes hold any token of the session.
Once the last process exits, the session reaches refcount zero, the event fires, and the user is logged out.
The kernel cooperates by exposing auth_id through token query interfaces (specifically the TokenStatistics query class via KACS_IOC_QUERY) and by providing the per-PID token handles at /proc/<pid>/token. authd uses both to do the walk.
There are two reasons the kernel does not provide a direct revoke:
- No graceful path. A "kill the session" syscall would need to choose between killing every process holding any of its tokens (loss of work, possible data corruption) and merely refusing future operations, which leaves running processes with stale identity. User-space can stage the teardown — send SIGTERM, wait, escalate — in a way the kernel cannot.
- Reference-counted identity is the simpler model. The rule "a session exists if and only if a token exists referencing it" has one fewer transition than "a session exists if and only if its tokens exist AND no revocation has been requested". Fewer transitions, fewer corner cases.
The cost is that revocation is observable: a thread can detect that its session is about to die (its parent process getting a TERM) before the kernel sees the session as gone. Anything that wants to enforce immediate revocation needs to design around that — typically by minimising the work a thread can do between receiving a signal and actually exiting.
Inspecting active sessions #
The kernel exposes the active session list at /sys/kernel/security/kacs/sessions. The file is a text listing, one session per line:
session_id=42 user_sid=<hex-encoded SID> logon_type=2 auth_package=<hex-encoded name> created_at=...
Lines are stable in their leading fields; new fields may be appended in a future version, so consumers must ignore unknown trailing fields. The file's own SD grants read to BUILTIN\Administrators and SYSTEM only.
This is the canonical way to enumerate sessions. Other tools — eventd consumers, who-equivalents, session monitors — can read it directly or through helpers that wrap it. See Inspecting tokens, sessions, and processes.
Where to go next #
For the token-side half of the same story — the references whose rise and fall drive a session's life — read Token lifecycle.
To list, create, and destroy sessions from a shell, read The logonse command.
The logonse command
Peios / Peios Security Fundamentals / Logon Sessions
logonse is the command-line tool for logon sessions — the kernel's records of authentication events that this topic describes. It lists the active sessions, shows which processes belong to one, creates and destroys sessions, and (as a related low-level job) sets a process's mitigation flags.
logonse subcommand [arguments]
$ logonse list
$ logonse show 4711
logonse is a low-level administrative and debugging tool. It requires a subcommand: list, show, create, destroy, or psb.
Listing sessions #
logonse list #
Enumerates the active logon sessions, and the process IDs in each.
$ logonse list
session 0 pids: [1, 2, 14, 22]
session 4711 pids: [820, 844, 901]
logonse show #
Shows the process IDs that belong to one session.
$ logonse show 4711
A caveat on list and show #
There is no syscall that enumerates logon sessions, and logonse does not read the kernel's sessions file (whose SD restricts it to Administrators and SYSTEM). logonse list and logonse show work by walking the running processes and reading each one's token to find which session it belongs to. That has two consequences worth knowing:
- It is best-effort. A session that has no running process — held alive only by a token file descriptor somewhere — will not appear, because there is no process to find it through.
- It is a snapshot under change. Processes start and exit while the walk runs, so the result is a close approximation of the moment, not a locked one.
For routine "who is signed in" use this is fine. For an authoritative listing, the kernel's own sessions surface — described in Inspecting sessions — is the source of record.
Creating and destroying sessions #
logonse create #
Creates a new logon session for a user, described by a logon type, an authentication-package name, and the user's SID.
$ logonse create --logon-type interactive --auth-package Negotiate --user-sid S-1-5-21-...-1001
| Flag | Meaning |
|---|---|
--logon-type TYPE | The kind of logon: interactive, network, batch, service, network-cleartext, or new-credentials. |
--auth-package STR | The name of the authentication package that vouched for the logon. |
--user-sid SID | The user the session belongs to, as an S-1-… SID or an SDDL alias such as BA. |
On success logonse prints the new session's id. Creating a session is a privileged operation — minting authentication records is reserved for the components that legitimately do so.
logonse destroy #
Destroys a session — but only an empty one, with no tokens still referencing it.
$ logonse destroy 4711
A session with live tokens cannot be destroyed this way; its tokens must go first. See Session lifecycle.
Setting process mitigation flags #
logonse psb #
logonse psb sets the mitigation flags in a process's Process Security Block.
$ logonse psb --pid 4821 --mitigations 0x1c0
| Flag | Meaning |
|---|---|
--pid PID | The process to act on. |
--mitigations MASK | The mitigation bitmask to apply, in hexadecimal or decimal. |
This subcommand is about process hardening rather than logon sessions — it lives in logonse because both deal with low-level per-process kernel state. For what the mitigation flags mean and how they behave, see Process mitigations.
Output options #
| Flag | Effect |
|---|---|
--json | Emit JSON instead of human-readable output. Accepted by every subcommand. |
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded. |
1 | A usage error, or the operation failed. |
Signing in
Peios / Peios Security Fundamentals / Signing In
Every sign-in on Peios is a conversation with an authority over a Unix socket at /run/logon.sock. The protocol is PGSS Logon, and it is a conformance requirement rather than an implementation detail: a system that does not offer it, at that path, with these semantics, is not Peios.
Knowing its shape is worth the few minutes it takes. It explains why a login prompt behaves the way it does, why a wrong password and an unknown account are indistinguishable, and what you would have to build to add a new way of signing in.
Three roles #
The authority listens on the logon socket. It decides whether a sign-in succeeds, and it is the only thing on the system that mints tokens. On a stock Peios system that is authd. There is at most one on a running system — two would make "who authenticated this?" unanswerable.
The client connects and speaks on the principal's behalf. login is a client. So is any greeter, remote access daemon or kiosk agent you might write. A client renders the prompts it is asked to render, returns the answers, and installs the token it is given. It is not trusted, and nothing it sends is taken as fact.
A principal source knows who exists and verifies credentials. It asserts an identity and can do nothing else — it cannot mint a token, grant a privilege or create a session, because the protocol it speaks has no message for any of those. lpsd is the local one. See managing local principals.
The conversation #
A client opens a connection and sends one LogonStart, naming the principal, the kind of session it wants, and the credential types it is able to collect. The authority may then ask for credentials — as many rounds as the source needs — and each request carries prompts the client renders without interpreting. The conversation ends when the authority grants or denies.
The capability list is a statement about the client, not a demand. Declaring that you can collect a password does not mean one will be asked for; declaring that you can collect nothing says you are able to complete a sign-in that needs no interaction, and nothing else. An authority faced with that either completes the sign-in or denies it. That is the mechanism behind passwordless accounts and console autologon.
A connection carries exactly one conversation, so the connection is the conversation's identity. There is no correlation identifier to forge.
The authority is not a process factory #
A grant carries a token as a file descriptor, a session identifier, and a profile. It carries nothing about what should happen next, because nothing about what happens next is the authority's concern.
The client installs the token itself and proceeds. login makes the token its own and then execs your shell, rather than the authority forking anything. This keeps the most privileged process on the system out of the business of launching programs: authd never learns about terminals, environments or session leadership, and stays small enough to audit.
It also means a client can obtain a token for a purpose the authority never anticipated.
Authentication and derivation are separate #
Two acts, deliberately distinguished.
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 this machine's policy.
The separation is what stops an identity established elsewhere from carrying entitlements onto this machine with it. A directory can tell this machine that you are a member of Domain Admins; whether that membership carries SeLoadDriverPrivilege here is answered here, every time, by the authority applying local policy. An authority never accepts a token, a privilege set or an integrity level from another party, whatever its trust level.
See privileges for what that policy contains.
What the authority does not trust #
Nothing a client sends. Three consequences are worth knowing because you will see them from the outside:
Peer identity comes from the socket. The authority reads the connected peer's token from the kernel rather than believing anything in a message, because there is no field a client could put its identity in that it could not also lie in. SO_PEERCRED is not used: it returns the projected uid, and on Peios many principals project to the same number.
The logon type is a proposal. Only the caller knows whether a connection is an interactive shell or a batch command, so the caller says — and the authority constrains that against what the verified peer is permitted to request. See logon types.
The identifier is a claim until authentication succeeds, and tty and remote_host are never more than that. A client that lies about its remote host is not prevented from doing so by this protocol.
A wrong password and an unknown account look identical #
An authority never distinguishes an unknown principal from a bad credential — not by denial code, not by the text it returns, and not by timing. There is one denial for both.
lpsd holds to the timing half by verifying an unknown name against a decoy: a real verifier over a password nobody knows, so both outcomes cost one full derivation. Returning early for a name it did not recognise would make "does this account exist?" answerable by anyone who can time a sign-in.
Account existence is not a secret, and Peios does not pretend otherwise — it is answered plainly on a different socket, /run/ident.sock, which is how ls -l turns an owner into a name. What the logon socket refuses to do is let you learn it by guessing credentials. See resolving names.
Where to start #
- The
logincommand — the terminal client, and how a live image signs in without being asked anything. - Logon sessions — the kernel object a successful sign-in creates.
- Managing local principals — the accounts being signed in to.
The protocol is specified rather than merely documented: PGSS §2 is Logon, and PSPU §2 is PSI, the interface principal sources speak. Read those if you are writing a client or a source of your own.
The login command
Peios / Peios Security Fundamentals / Signing In
login is the terminal client for signing in. It collects an identifier, renders whatever credential prompts the authority sends, installs the token it is granted, and replaces itself with your shell.
login [name] [options]
login --try <name> [options]
login --try-no-password <name> [options]
It does not know what a password is. It renders the prompts it is asked to render and returns the answers, so adding a new credential type — a one-time code, a smartcard — changes the authority and leaves login alone.
Naming a principal, or not #
With no name, login asks for one:
Username: alice
Logging in as alice
Password:
With a name, the first prompt is skipped. The Logging in as line comes from the authority rather than from login, so it will gain a realm once authorities have them.
A principal who needs no credential is signed in immediately, with no prompt at all — see passwordless accounts.
--try and --try-no-password #
Both attempt a named principal and fall back to an ordinary prompt rather than failing. They differ in what they offer to collect.
--try <name> attempts the principal with login's full capabilities. A passwordless principal is signed in immediately; one with a password is prompted for it.
--try-no-password <name> attempts the principal while declaring that it can collect nothing, so only a principal who needs no credential can succeed. This is what a console autologon uses.
Neither flag asserts that anyone is authenticated. The authority still decides, and understating what you can collect only denies you prompts you could have rendered — so running either by hand gains you nothing you did not already have.
| Passwordless | Has a password | No such principal | |
|---|---|---|---|
login alice | signed in | prompts | user alice does not exist |
login --try alice | signed in | prompts, then falls back if wrong | falls back silently |
login --try-no-password alice | signed in | falls back silently | falls back silently |
A fallback restarts the sign-in from Username:, so you are never locked to the principal that was attempted.
When a fallback explains itself #
login says why it fell back only if something was already on your terminal.
A --try that reached a password prompt has interrupted you, and dropping to Username: without a word would read as a fault — you typed a password and got asked for a name. So it reports first:
$ login --try alice
Logging in as alice
Password:
login: Password incorrect
Username:
A --try-no-password that was refused before anything was rendered has interrupted nobody, and stays quiet. That is what keeps a line about a failed autologon off the console of every machine where the principal simply has a password.
Falling back is limited to denials another principal could survive. A failure of the authority itself is reported and login exits, because offering a prompt that cannot work either would spin a console.
Where existence is checked #
login asks /run/ident.sock whether a principal exists, not the logon socket. The logon socket will not tell it — an authority never distinguishes an unknown principal from a bad credential — while the identity socket answers plainly, which is what it is for.
If that lookup cannot be answered, login attempts the sign-in anyway rather than reporting an absence. An unreachable source reported as "no such user" would turn an outage into a fact.
Autologon on a console #
A console that signs in on its own is a passwordless principal plus --try-no-password. On a live image, peinit starts:
/bin/login --try-no-password peios
The same service definition suits an image where peios has a password: the attempt is refused before anything is rendered, and an ordinary prompt appears. No second seed, and no conditional configuration.
To turn autologon off, give the principal a password with lps password. To move the prompt to another terminal, change the service's TTYPath — see controlling services.
Options #
| Option | Effect |
|---|---|
--try <name> | Attempt name, falling back to a full prompt. |
--try-no-password <name> | Attempt name collecting nothing, falling back to a full prompt. |
-p | Keep the inherited environment instead of building a fresh one. |
-h <host> | Record the remote peer for a sign-in originated on its behalf. Unverified. |
-H | Accepted and ignored. Suppresses the hostname banner on other systems. |
-- | Treat everything after as a name, not an option. |
-f is not implemented. On Linux it means "trust me, they are already authenticated", gated only by the caller being root. On Peios that decision belongs to the authority, taken from the verified peer, so accepting a flag here would put an authentication bypass in an unprivileged process.
The environment your shell starts in #
Without -p, login builds a fresh environment: HOME, SHELL, USER, LOGNAME, PATH=/bin, and TERM carried through from its own. The shell's argv[0] gets a leading dash, which every shell reads as "this is a login shell, run the profile files".
The home directory comes from the profile the authority sent, and a missing one is not fatal. login reports it and starts you in /. Refusing to proceed over an absent directory would turn a cosmetic problem into being locked out, and creating it would put directory provisioning inside the one program that has to keep working when everything else is broken.
Exit status #
login does not return on success — it replaces itself with your shell, so what you see afterwards is the shell's exit status.
| Code | Meaning |
|---|---|
1 | A usage error, a denied sign-in, or the shell could not be started. |
Managing local principals
Peios / Peios Security Fundamentals / Managing Local Principals
Signing in on Peios involves three separate things, and it is worth being able to name them before you administer any of them.
The authority is the process that mints tokens. On a stock Peios system that is authd. It listens on /run/logon.sock, it holds the privilege to create tokens, and it is the only thing on the system that does. When you type a password at a login prompt, login is talking to authd.
A principal source is a process that knows who exists. It verifies credentials and answers the question "who is this?" — and nothing else. It cannot mint a token, cannot grant a privilege, and cannot create a session, because the protocol it speaks gives it no way to say any of those things.
lpsd is the local principal source: the one that holds this machine's own accounts. It is where jack lives, and it is what you are administering when you run lps.
Why the split #
It would be simpler to have one daemon that both checks passwords and mints tokens. The split exists because those two jobs have very different risk profiles.
Checking a credential means parsing input that came from outside — from a login prompt, a network connection, a smartcard reader. That is the code most likely to have a defect in it. Minting a token is the most privileged operation on the system.
Putting them in one process means a defect in the first becomes a compromise of the second. So authd mints and cannot verify; lpsd verifies and cannot mint. A completely compromised lpsd can lie about the accounts it holds, and that is the whole of what it can do — it cannot elevate anyone, cannot forge a token, and cannot claim identities that are not its to claim.
The division runs through what a source is even able to say. A source states who someone is: their SID, their memberships, their POSIX identifiers, where their session starts. It never states how much this machine trusts them — privileges and integrity levels are authd's, decided from local policy, and there is no message with which a source could ask for one.
That local policy is a registry key, one record per principal, and it is where you go to change what an account may do as opposed to who it is: assigning privileges.
How they find each other #
lpsd connects to authd, not the other way round. That keeps authd — the process holding the token-minting privilege — free of any reason to open an outbound connection to anything.
At boot, lpsd starts after authd, connects to /run/psi.sock, registers itself, and only then reports itself ready. That ordering means anything that starts after lpsd — login, a greeter, a remote access daemon — finds a system that can genuinely authenticate, rather than one where the processes merely exist.
If you see lpsd failing to start, the usual causes are that authd is not running, or that this machine's configuration does not permit lpsd to register. The second is deliberate: a source must be explicitly allowed to assert identity, and an unconfigured machine permits none.
More than one source #
lpsd is the only source on a stock machine, but nothing about the design assumes it is alone. A directory-backed source can register alongside it, holding domain accounts while lpsd holds local ones, and authd routes each logon to whichever source owns the name.
Each source declares the SID namespace it is authoritative for, and authd confines it there. That is what stops a directory deciding who administers your machine, and equally what stops a local source handing out domain identities.
The same confinement applies to numbers. A source is given a range of POSIX identifiers and counts inside it, and authd adds the base — so a source's principals land in its own range whatever the source sends, and the numbers below every range, uid 0 among them, belong to authd alone. lpsd starts at 1,000,000; a directory source would be given somewhere well clear of it.
That is why a uid on a Peios machine is larger than you might expect, and why moving a source's range is a configuration change rather than a migration: nothing a source stores was ever an absolute number.
Seeing a principal, rather than a number #
Signing in is one half. The other is that everything on the system can turn an identifier back into a name — ls -l showing an owner, id showing a group.
That goes to authd too, on a socket of its own, and for a reason worth knowing: a source counts its POSIX identifiers relative to a range it is never told the base of, so only the authority can work out who a uid belongs to. See resolving names.
Where to start #
- The local store — what
lpsdkeeps, and where. - Resolving names — how a name, a SID or a number becomes a principal.
- Creating accounts — the first one, and every one after.
- The
lpscommand — the full reference.
The protocols themselves are specified rather than merely documented: PGSS §2 is Logon, and PSPU §2 is PSI, the interface sources speak. Read those if you are writing a principal source of your own.
The local store
Peios / Peios Security Fundamentals / Managing Local Principals
lpsd keeps everything it knows in one file:
/var/state/lpsd/principals
It is read once at startup and held in memory. It changes only when an administrator changes it.
What is in it #
The machine's domain SID. Every local principal is numbered under it, and it is generated — from the kernel's random number generator — the first time lpsd runs on a machine that has no store.
That last point matters more than it sounds. Two machines built from the same image do not share a domain, so the security descriptors written on one do not grant access to the other's users. An image cannot ship a domain, because the domain does not exist until first boot.
The domain is always of the form S-1-5-21-A-B-C. That shape is structural — the store holds three numbers under a fixed prefix, so it cannot express a domain of any other kind. It cannot claim S-1-5-32 (BUILTIN) or any well-known namespace, whatever is written into it.
The principals. For each: a relative identifier (RID), a Unix ID, the canonical spelling of the name, whether the account is enabled, a password verifier, the groups it belongs to, which of them is primary, a home directory, a shell, a display name, and any claims.
A principal's SID is the domain plus their RID. jack with RID 1000 in domain S-1-5-21-A-B-C is S-1-5-21-A-B-C-1000.
RIDs start at 1000 and are never reused. Not even after an account is deleted. That is deliberate and it is the property that makes deletion safe to offer at all: a reissued RID would give a new person the SID of an old one, silently inheriting every access the descriptors on this machine still grant them. It is the one identity mistake that cannot be undone by fixing the account afterwards.
The local groups. A group created here is an object: a RID from the same counter, a name, and a Unix ID. Sharing the counter with principals is what stops a group ever colliding with a user's SID.
Well-known groups — BUILTIN\Administrators, Everyone, Authenticated Users — are not stored. Their SIDs are the same on every Peios machine, so a stored copy could only ever drift from the real one, and provisioning would freeze whatever the table said that day. lpsd resolves their names from a table in its own code.
Unix IDs #
Linux programs call getuid and getgroups and have never heard of a token, so every token carries POSIX numbers alongside the SIDs. Those numbers begin here.
A principal's Unix ID is their RID, and a group's is its RID. One object, one number — so a uid in a log tells you the RID without a lookup.
The numbers stored here are relative. lpsd knows nothing about where its range sits: authd adds a base from the registry before the number reaches a token, so jack with RID 1000 stores 1000 and signs in as uid 1001000 when the base is 1,000,000.
That indirection is not bookkeeping. It is what stops a principal source reaching uid 0 or another source's numbers — authd reserves everything below the sources' ranges for its own, and refuses a relative number that runs past the end of a range rather than wrapping it. A source can only ever express numbers inside the range it was given.
It also means moving a range is a registry edit rather than a rewrite: nothing stored here was ever absolute.
lps list and lps show display the effective number, with the base already added, because that is the one an operator will see everywhere else.
The same indirection is why getpwuid has to reach authd rather than lpsd: the arithmetic that made a number absolute happened in authd, and only authd can run it backwards. See resolving names.
Profiles and claims #
The profile — home directory, shell, display name — is not identity. No security descriptor names a home directory and no token carries one. It is stored here because this is where an administrator sets it, and it travels to login on the logon reply so a session can start without a second lookup.
Claims are named, typed attributes that conditional ACEs read: a descriptor can grant access to anyone whose Department is Engineering without naming principals individually. They are fixed on a token when it is minted, so a claim set now takes effect at the principal's next sign-in.
What is not in it #
Passwords. What is stored is a verifier — an argon2id hash — and a verifier is deliberately not password-equivalent. You cannot authenticate with it, and it cannot be used to answer a challenge.
That is the property that makes stealing the store meaningfully weaker than knowing the passwords. It is also why Peios does not use challenge-response schemes: those require storing something a response can be recomputed from, which is exactly what this avoids.
Each verifier carries the cost parameters it was created with, so raising them applies to passwords set afterwards while existing accounts keep working.
Well-known group definitions. The store defines the groups this machine creates, and records memberships of everything else as SIDs. BUILTIN\Administrators exists on every Peios machine whether or not this store mentions it; lpsd only records that jack is in it.
Privileges and integrity levels. What a principal may do is not stored here at all. authd decides it, from local policy keyed on the SIDs a token ends up carrying — a registry key rather than this file, so that it stays readable by an administrator and this file can stay readable by nobody. A principal source says who someone is; it has no way to say how much this machine trusts them. See assigning privileges.
The descriptor #
The store file is owned by LocalSystem and its DACL grants LocalSystem and nothing else — not even BUILTIN\Administrators.
That is narrower than almost anything else on the system, and it is on purpose. This is the machine's password material, and the list of principals entitled to read it should be as close to empty as the system permits. An administrator who genuinely needs the file can take ownership, which is an act that leaves a trail; a read granted by the DACL does not.
It is also why lps talks to lpsd over a socket rather than editing the file. A tool that wrote the file would need that descriptor widened to whatever the tool runs as, undoing the one thing it exists to do.
A file, not a database #
The store is serialised whole and replaced atomically: written to a temporary file, flushed, then renamed over the old one.
A few hundred principals, rewritten when an administrator changes an account, is not a workload that needs a write-ahead log. And because the swap is a rename, a reader sees the old file or the new one and there is no third outcome — which means there is no recovery code, and code that does not exist cannot be wrong.
The file carries a checksum. Not to catch a half-written file, which atomic replacement makes impossible, but because a corrupted store that decoded short would present as an account quietly no longer existing.
Missing versus corrupt #
These are treated as opposite outcomes, and the distinction is the most important behaviour in this page.
No store at all means an unprovisioned machine. lpsd generates a domain, writes an empty store, and carries on.
A store that will not read is fatal. lpsd refuses to start.
Collapsing the two — "cannot read it, so make a new one" — would turn a flipped bit into every account on the machine silently ceasing to exist, and then reappearing under a new domain with different SIDs, orphaning every security descriptor that named them. Refusing to start is loud, reversible, and leaves the evidence intact.
If lpsd reports that the store is corrupt, do not delete it. Take a copy first: it is the only record of your machine's identity.
Creating accounts
Peios / Peios Security Fundamentals / Managing Local Principals
Creating an account #
Run lps add with no arguments and it asks for what it needs:
$ lps add
Name: alice
Full name [optional]: Alice Chen
Additional groups [comma-separated, optional]: Administrators
Primary group [the daemon's default]:
Home directory [the daemon's default]:
Shell [the daemon's default]:
Password for alice:
Again:
name alice
full name Alice Chen
primary group (the daemon's default)
home (the daemon's default)
shell (the daemon's default)
groups Administrators
Create this principal? [Y/n]:
created alice with RID 1002
An empty answer takes the default, and nothing is sent until you confirm. Or say it all on one line:
$ lps add alice --group Administrators
Anything you supply is not asked for, so either style works and they mix freely.
Groups are named however you like — Administrators, a local group's name, or a literal SID such as S-1-5-32-544. lpsd resolves it.
You need to be an administrator to do this. See the lps command for the full surface.
What a new account gets #
| RID | The next one. Never chosen, never reused. |
| uid | The RID, plus this machine's base — so RID 1000 signs in as 1001000. |
| Primary group | Authenticated Users, unless you say otherwise. |
| Home | /home/<name> |
| Shell | /bin/sh |
No per-user group is created. That Linux convention exists so a file's group ownership means something, and under KACS it means nothing — access is decided by the token, not by the mode bits. A group per user would be ceremony with a RID attached.
The home directory is not created either. lps records the path; nothing makes the directory. A principal whose home is missing still signs in, starting in /, and login says so.
Accounts with no password #
lps add <name> --no-password creates a principal who signs in without being asked for anything.
The sign-in is otherwise completely ordinary. lpsd still vouches for the principal, authd still mints the token and still applies this machine's privilege and integrity policy to it, and the session that results is indistinguishable from one reached by typing a password. Nothing is bypassed; there is simply nothing to collect.
It is a property of the account. Nothing ties it to a particular terminal, so the principal can be signed in at any logon prompt the machine offers — the console today, a remote one later. If what you want is "this console signs in automatically", a passwordless account is how Peios spells it, and the breadth is the cost.
An empty password is not the same thing and is refused. See the lps command.
Give the first account Administrators #
On a stock machine the tree-wide security descriptor grants LocalSystem and BUILTIN\Administrators, and nothing else.
An account without Administrators can traverse to a file it names exactly, and can execute it — but it cannot list a directory, because nothing bypasses that check. The symptom is a shell that appears to work until you type ls.
That is a limit of the descriptor the system ships with rather than anything about the account. Until per-subtree descriptors exist, an ordinary non-administrative account on a stock image is not comfortable to use.
Where the first account comes from #
A machine with no store provisions one at first boot, and it is empty — lpsd generates the domain, because a source with no domain can answer nothing, but it does not invent accounts.
So something has to create the first one, and there is a chicken-and-egg problem: lps talks to lpsd, so lpsd must already be running, which means this cannot be done by an early-boot script. Autorun scripts run before any service has started.
The answer is a oneshot service, ordered after lpsd, that runs lps like anything else would. On a development image that service exists and creates a known account. On a production image it does not, and an administrator creates the first account themselves.
On a development image #
Images built for development ship two things: a script that creates a known account, and a registry seed defining the oneshot service that runs it. If your image has them, it boots with an account already present, and lps list will show it.
On a live image that account has no credential at all, and it is an administrator. The script creates it with lps add peios --group Administrators --no-password, so anyone who reaches a logon prompt on that machine can become it — and the console signs in as it automatically, without asking anything.
That is deliberate rather than an oversight. A live ISO is an unauthenticated medium: anyone holding it can boot it and read everything on it, so a password would be a formality rather than a boundary, and one printed in the image at that. What it is not is a posture to carry anywhere else.
Give it a password the moment the machine becomes something you care about, which turns it into an ordinary account and stops the console signing in on its own:
$ lps password peios
New password for peios:
Again:
set the password for peios
Better still, build an image without the seed. See On an image without one below.
On an image without one #
lpsd starts, provisions an empty store, and logs that no principals exist and no logon can succeed until one is created. The login prompt will appear and nothing will satisfy it.
That is the correct behaviour rather than a fault, but it does mean you need a way in. Create the first account from a console session that is already SYSTEM — lps accepts LocalSystem as well as administrators, precisely for this case.
Disabling rather than deleting #
$ lps disable guest
disabled guest
A disabled account keeps its RID, its SID, and its group memberships. It simply cannot sign in. Re-enable it with lps enable.
Prefer this to lps remove. A removed principal's SID keeps appearing in the security descriptors of everything they owned, and because RIDs are never reused nothing will ever hold that SID again — the files become owned by a principal that no longer resolves.
You cannot lock yourself out #
lps refuses to remove the last enabled administrator, to disable them, or to take Administrators away from them.
$ lps disable jack
lps: jack is the only principal who can administer this machine; disabling them would leave no way back in
The check counts only enabled administrators, so a disabled standby account does not license removing the working one.
This guard exists because there is currently no offline repair. lps reaches the store only through lpsd, and lpsd only authenticates — so a machine with no enabled administrator has no way back short of editing its disk from another system.
Grouping people together #
For anything beyond the built-in groups, create one:
$ lps group create developers
created the group developers with RID 1001
$ lps group add alice developers
added alice to developers
A local group is a real object with its own SID and gid, so it can be named in a security descriptor and it shows up in getgroups. Deleting one is refused while anybody is still in it.
Changing your own password #
Not yet. lps password is an administrator resetting somebody else's.
Changing your own password will go over PGSS Logon rather than through lps, so that it works the same way whichever source holds your account — a domain principal will change their password exactly as a local one does. Until then, an administrator resets it for you.
Where to go next #
For the full command surface — every flag, the group and claim subcommands, and the exit statuses — read The lps command.
For what the store holds and why RIDs are never reused, read The local store.
The lps command
Peios / Peios Security Fundamentals / Managing Local Principals
lps administers the local principal store — this machine's own accounts and groups, held by lpsd.
lps subcommand [arguments]
It is a client and nothing more. It holds no state, opens no store, and has no privilege of its own: every command is a request over /run/lpsd/admin.sock that lpsd decides whether to honour. lpsd must be running.
You must be a member of BUILTIN\Administrators — enabled, not deny-only — or be LocalSystem.
Naming a group #
Anywhere lps takes a group, you may write it three ways:
lps group add jack Administrators # a well-known name
lps group add jack BUILTIN\\Administrators # the same, qualified
lps group add jack developers # a local group
lps group add jack S-1-5-32-544 # a literal SID
Names are matched case-insensitively, and lpsd resolves them, not lps. The tool carries no copy of the well-known table and does not know this machine's domain — a second copy of the machine's identity scheme would be free to disagree with the first.
A well-known name always wins over a local group of the same name, which is why you cannot create a local group called Administrators.
Inspecting #
lps list #
Every principal, with RID, uid, state, and how many groups each is in.
$ lps list
NAME RID UID STATE GROUPS
jack 1000 1001000 enabled 1
guest 1001 1001001 disabled 0
A - in the UID column means nothing numbers that principal — authd has assigned this machine no identifier range, and they will sign in as nobody. See the local store.
lps show <name> #
One principal in full.
$ lps show jack
name jack
display name Jack Palfrey
rid 1000
sid S-1-5-21-2847362817-1094533892-3310298447-1000
uid 1001000
state enabled
primary group Authenticated Users [S-1-5-11]
home /home/jack
shell /bin/sh
groups Administrators [S-1-5-32-544]
developers [S-1-5-21-2847362817-1094533892-3310298447-1001] (gid 1001001)
claims Department (string) = "Engineering"
Groups show a name where this machine knows one, and always show the SID. The two are kept side by side deliberately: the name is what you recognise, the SID is what a security descriptor actually holds, and the moment they disagree is exactly when you need to see both.
Only local groups show a gid. lpsd numbers the groups it owns and nothing else — Administrators and Authenticated Users are numbered by authd, from a table below every source's range, so lpsd genuinely does not know their gid and does not guess. To see the numbers a running session actually holds, read Groups: in /proc/self/status.
lps domain #
This machine's domain SID — the namespace every local principal and local group is numbered under.
$ lps domain
S-1-5-21-2847362817-1094533892-3310298447
Generated at first boot and unique to this machine.
Creating and removing principals #
lps add [name] [options] #
Creates a principal. Run with no arguments, it asks for what it needs:
$ lps add
Name: alice
Full name [optional]: Alice Chen
Additional groups [comma-separated, optional]: Administrators, developers
Primary group [the daemon's default]:
Home directory [the daemon's default]:
Shell [the daemon's default]:
Password for alice:
Again:
name alice
full name Alice Chen
primary group (the daemon's default)
home (the daemon's default)
shell (the daemon's default)
groups Administrators, developers
Create this principal? [Y/n]:
created alice with RID 1002
Any option you supply is not asked for, so scripted invocations keep working unchanged:
| Option | Effect |
|---|---|
--group <group> | A membership. Repeatable. |
--primary-group <group> | The group that becomes the POSIX gid. |
--home <path> | Home directory. Must be absolute. |
--shell <path> | Login shell. Must be absolute. |
--display-name <text> | A human's name for a human to read. |
--disabled | Create it unable to sign in. |
--no-password | Create a principal that authenticates with no credential at all. See below. |
--no-prompt | Fail rather than ask for anything missing. |
Nothing is sent until you confirm, so answering n creates nothing.
lps never prompts when standard input is not a terminal. A prompt down a pipe would consume the next line of whatever is driving the tool. See From a script below.
The RID is allocated by lpsd and reported back. You do not choose it, and the uid follows from it.
--no-password #
Creates a principal who signs in without being asked for anything. No password is collected, including in the interactive flow — an operator who has said the account needs no credential is not then asked to invent one.
$ lps add kiosk --group Administrators --no-password --no-prompt
created kiosk with RID 1003
This is a property of the account, not of a terminal. Nothing scopes it to the console: the principal can be signed in at any logon prompt the machine offers, now or later. That is the right posture for a live image, where the medium is unauthenticated anyway and anyone holding it can read everything on it. It is the wrong posture for almost anything else.
An empty password is not a way to spell this, and lps refuses one:
$ lps add alice
...
Password for alice:
Again:
lps: an empty password is not a password: pass --no-password to create a
principal that authenticates without one
The two produce accounts that behave differently — an empty password is still collected, still prompted for, and still has to be answered — so having both spellings would mean two things that look identical at creation and diverge at every sign-in. lpsd refuses an empty password too, so the rule holds however the request arrives.
lps remove <name> #
Deletes a principal.
The RID is not reclaimed. Files the principal owned keep naming a SID that now resolves to nobody, and nothing will ever hold that SID again. lps disable is usually what you wanted — see creating accounts.
Enabling and disabling #
lps enable <name> / lps disable <name> #
A disabled principal keeps everything except the ability to sign in.
Both are refused if they would leave the machine with no enabled administrator.
Passwords #
lps password <name> #
Sets a principal's password, prompting twice.
$ lps password jack
New password for jack:
Again:
set the password for jack
This is an administrator resetting somebody else's password. Changing your own will go over PGSS Logon instead, so that it works identically whichever source holds your account.
An empty password is refused here for the same reason it is refused at creation. Giving a passwordless principal a password works and makes them an ordinary account; there is currently no command that takes one away again, so a principal created with --no-password can gain a credential but not shed one.
From a script, lps reads a single line from standard input when it is not attached to a terminal, and does not ask for confirmation:
printf '%s\n' "$password" | lps add alice --group Administrators --no-prompt
Profiles #
lps set <name> [options] #
Changes part of a profile, leaving the rest alone.
$ lps set jack --shell /bin/bash --display-name "Jack Palfrey"
updated jack
| Option | Effect |
|---|---|
--home <path> | Home directory. Absolute. |
--shell <path> | Login shell. Absolute. |
--display-name <text> | Empty clears it. |
--primary-group <group> | Which group projects to the POSIX gid. |
None of this is identity — no security descriptor names a home directory, and the token carries none of it. It is what login needs in order to start a session, and it reaches login on the logon reply itself.
lps does not create the home directory. It records the path. A principal whose home does not exist still signs in, starting in /, and login says so.
The primary group need not be a membership: authd adds it to the token if it is missing, so the order of two commands does not matter.
Groups #
Local groups are objects with a name, a RID and a gid — unlike well-known groups, which exist on every Peios machine and are not stored here.
lps group list #
$ lps group list
NAME RID GID MEMBERS SID
developers 1001 1001001 2 S-1-5-21-2847362817-1094533892-3310298447-1001
MEMBERS counts principals in this store, which is the only count lpsd can answer for.
lps group create <name> / lps group delete <name> #
$ lps group create developers
created the group developers with RID 1001
Deleting is refused while anyone is still a member, or while the group is anybody's primary group. Either would leave a record pointing at something that no longer exists.
lps group add <name> <group> / lps group remove <name> <group> #
$ lps group add alice Administrators
added alice to Administrators
Removing BUILTIN\Administrators is refused if it would leave no enabled administrator.
Claims #
Claims are named, typed attributes carried on the token and read by conditional ACEs. They are how a security descriptor can say anyone in Engineering rather than naming principals one by one.
lps claim set <name> <claim> <type> [value]... #
$ lps claim set jack Department string Engineering
set the claim Department on jack
$ lps claim set jack Level int64 7
set the claim Level on jack
| Type | Values |
|---|---|
int64 | Signed integers |
uint64 | Unsigned integers |
boolean | true/yes/1 or false/no/0 |
string | Text |
sid | SIDs, in S-1-… form |
octet | Hexadecimal, even length |
A claim may hold several values, and may hold none — lps claim set jack Department string empties it, which is different from removing it.
Claim names are matched case-insensitively, so setting department replaces Department.
lps claim remove <name> <claim> #
$ lps claim remove jack Department
removed the claim Department from jack
A claim reaches a principal at their next sign-in. A token's claims are fixed when it is minted, so setting one changes nothing for a session already running.
Exit status #
| Code | Meaning |
|---|---|
| 0 | Succeeded |
| 1 | lpsd refused the request, or could not be reached |
| 2 | The command line was wrong |
The split lets a script distinguish "I called this incorrectly" from "it was refused".
Common failures #
cannot reach lpsd … it does not appear to be running — lpsd is not up. It exits if it cannot reach authd, or if the store will not read; check its logs before restarting it.
permission denied — you are not an administrator, or the socket's descriptor does not admit you. See the administrative socket.
there is no principal named … — names are case-insensitive, so this means the account genuinely is not there. lps list will show what is.
there is no group named … and it is not a SID — the name matched no well-known group and no local one, and does not parse as a SID. lps group list shows the local ones.
The administrative socket
Peios / Peios Security Fundamentals / Managing Local Principals
lpsd listens on:
/run/lpsd/admin.sock
lps is its only client today. 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.
A KACS descriptor is defence in depth. lpsd stamps one on its runtime directory and socket, admitting LocalSystem and BUILTIN\Administrators.
That descriptor is load-bearing in a way worth knowing about: /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 command that reports success has persisted. A command that reports failure changed nothing — not "possibly changed something", but nothing: lpsd keeps a snapshot and restores it.
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 not itself specified, because unlike those two it is one daemon's administrative interface rather than a contract anyone else implements.
If you are writing tooling, prefer driving lps and parsing its output over speaking the protocol directly. The command's surface is stable; the protocol's is not promised to be.
See also #
- The
lpscommand — the client this socket serves. - Managing local principals — the authority/source split behind the design.
Resolving names
Peios / Peios Security Fundamentals / Managing Local Principals
Every program that prints a name for a file's owner is asking the same question: who is this? On Peios that question goes to authd, over a socket of its own:
/run/ident.sock
Separate from /run/logon.sock, but the same standard — PGSS §2 defines both. What separates them is who calls: a handful of things ever originate a logon, where every process on the system resolves names. A filesystem walk is millions of lookups, and it must not be able to fill the queue an administrator needs in order to sign in.
Why the authority, and only the authority #
A principal source counts POSIX identifiers relative to a range it is never told the base of. authd adds the base — so jack, stored by lpsd as 1000, signs in as uid 1001000.
That arithmetic exists in exactly one place, and only that place can run it backwards. No source can answer "who is uid 1001000", because no source knows what 1001000 means. It is not that the authority is a convenient place to put this; it is the only party that can do it at all.
The same holds for names. A bare name may exist in more than one source, and which one wins is a property of the machine rather than of any source in it.
One answer, systemwide #
Whatever asks — a Peios tool, a Linux program through getpwnam, a future graphical console — the question goes to the same place and gets the same answer.
There is deliberately no way to arrange otherwise. glibc's identity databases are patched to reach the authority and nothing else, there is no /etc/passwd behind them, and PAM does not exist on Peios at all. Every route by which another system lets identity come from somewhere the authority cannot see has been closed rather than configured.
That is worth more than it sounds. If two components resolved names separately they could disagree, 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. It also means a name resolves identically at a logon and at an ls -l, because the same code answers both.
The search order #
A name nobody qualified is resolved by asking sources in a configured order, stopping at the first that answers.
The order is a value on each source's allowlist entry:
Machine\Software\Authd\Sources\lpsd
SearchOrder REG_DWORD 1000
Lower is consulted first. Sources that share a value are ordered by name. A source that sets nothing resolves at 1000, which leaves room to place a new source either side of the ones already there without renumbering them.
It is deliberately explicit. Resolving in the order sources happened to register would let a slow disk change which principal a name refers to.
Bare in, qualified out #
You may look a principal up by a bare name. What comes back always carries the SID as well as the name.
So a tool can always tell which principal it got, compare two answers for identity, and record something unambiguous in a log. Ambiguity is permitted in what you ask; it is not permitted in what the authority answers.
Reserved characters #
A principal or group name may not contain @, \, /, : or ,, may not begin or end with a space, and must be printable ASCII.
Each of those is a separator somewhere a name ends up — a path, a passwd record, a group member list — and the damage is done by whatever reads it later, so it cannot be prevented at the point of display. @ and \ are reserved for qualified names (jack@local), which do not exist yet: reserving them now is what stops an account literally called jack@local colliding with the syntax the day it arrives.
The ASCII restriction defers confusables and normalisation rather than getting them wrong. jack spelled with a Cyrillic а renders identically and is a different principal, and the same name in NFC and NFD is two byte sequences a comparison calls two people. Relaxing this later is safe; tightening it later would mean renaming accounts.
Absent is not the same as missing #
Three answers, and the difference between the last two is the one worth knowing:
| Answer | Means |
|---|---|
| Found | Here is the principal. |
| Not found | Every source that could have answered was asked, and there is no such principal. |
| Unavailable | A source that could have answered did not. |
A source that is configured and not running does not simply drop out of the order. If lpsd has crashed, jack resolves to unavailable — not to a directory's jack further down the order, which would be a different principal with a different SID, and every security descriptor granting the local one would stop applying to the person signing in under that name.
It also matters because not found is safe to remember and unavailable is not. A cache told "no such user" during an outage would keep saying so long after the outage ended.
Groups you cannot list #
Ask who is in a local group and you get its members. Ask who is in Everyone and you get nothing — and that is the correct answer rather than a limitation.
Membership comes in three kinds:
Recorded. Local groups, and BUILTIN\Administrators. A source holds the edges, so it can list them. (Note that being well-known has nothing to do with it — Administrators is well-known and perfectly enumerable.)
Stapled. Everyone, Authenticated Users. Nothing records who is in them; authd adds them to every token it mints. Membership is a rule, not data, so there is no list to produce.
Session-scoped. Interactive, Network, Batch, Service. These are not properties of an account at all, but of a logon: jack is in Interactive at the console and not in it over the network. No static answer exists even in principle, which is why they have no POSIX group id either.
The asymmetry that falls out is the useful one. Which groups is this principal in is cheap and always works. Who is in this group is best-effort, and a source is entitled to decline it — listing a directory group can mean listing an entire organisation.
Every lookup is live #
There is no cache yet. Each lookup is a fresh round trip to the source that holds the answer.
That is a deliberate first step rather than an oversight: a cache is invisible on both sides of the wire, so adding one later changes nothing about how any of this behaves. What it means today is that listing a very large directory is slower than it will be.
Sources already declare what a cache will need — whether they can push a change notification, and how long an answer may be held — so the contract is in place before the thing that uses it.
Where to go next #
For how Linux programs reach this resolution through getpwnam and friends, read Name service switch.
For where the local principals being resolved actually live, read The local store.
Impersonation
Peios / Peios Security Fundamentals / Impersonation
Impersonation is the mechanism that lets one thread temporarily act as another principal. The thread installs a second token — an impersonation token — and from that moment until it reverts, every access decision on that thread reads the impersonation token instead of the thread's primary. Other threads in the same process are unaffected. The primary token is preserved untouched and resumes its role the instant the thread reverts.
Impersonation exists because of one stubborn fact: most code that handles user requests does not run as the user. A storage service runs as a service account, a database runs as postgres, the registry daemon runs as loregd. When a real user asks one of these services to do something, the work has to be evaluated against the user's rights, not the service's. Impersonation is how that swap happens cleanly.
The two-token model #
A thread can have two tokens at once:
flowchart LR
A["Thread"] -->|primary token, always| B["Primary token, the service's identity"]
A -->|impersonation token, while impersonating| C["Impersonation token, the client's identity"]
While the impersonation token is installed, every AccessCheck on that thread reads it. The primary is invisible until revert. Other threads in the same process still read their own primary tokens — impersonation is strictly per-thread.
Two consequences worth knowing up front:
- The process's identity does not change. Other processes inspecting this one still see the primary token as the process's identity. Tools like
ps,/proc/<pid>/token, and audit events that record the process identity all report the primary. Only the thread's own view is overridden. - The PSB does not change. A thread impersonating a higher-trust token does not get higher process integrity protection. PIP is a property of the binary running, not of the impersonation token. See Process integrity protection for the why.
A canonical server flow #
The simplest case — one OS thread, one request, start to finish — follows a flow like this:
flowchart LR
A["Client connects"] --> B["Server accepts"]
B --> C["Server calls kacs_impersonate_peer(fd)"]
C --> D["Server does the work"]
D --> E["Server calls kacs_revert()"]
E --> F["Server handles next request"]
- Client connects. Before connect, the client may have called
kacs_set_impersonation_levelon the socket to bound how far its identity may travel (see Impersonation levels). - Server accepts. The kernel captures the client's identity onto the socket at connect time.
- Server impersonates. A call to
kacs_impersonate_peer(fd)installs the captured identity onto the calling thread, at the effective level determined by the two-gate model. - Server does the work. Every access check on this thread now runs against the client's identity. File opens succeed if the client could open the file; registry reads succeed if the client could read; and so on.
- Server reverts.
kacs_revert()drops the impersonation token. The thread is back to its primary identity for the next request.
A server that handles many concurrent clients impersonates separately per thread. A server that handles requests serially impersonates and reverts in a loop. Either way, the impersonation is always a bracketed operation — install, do work, revert.
Just-in-time impersonation #
The canonical flow above assumes one OS thread is dedicated to one request from start to finish. That model fits a thread-per-connection server, but it does not fit most modern application runtimes. In Go, a goroutine can be scheduled onto any of the runtime's worker threads, and may move between them at any await point. In Rust's async runtimes, in Java's virtual threads, in Node's event loop — wherever you have M:N scheduling, the "impersonate at the top of the request, revert at the bottom" pattern is unsafe. The impersonation is installed on whichever OS thread happened to be running when the call was made; the request may continue on a different OS thread an instant later.
Most real Peios applications use just-in-time impersonation instead: capture the client's identity as a token fd at request start, hold the fd in the request context, and install it on the current OS thread only for the brief moment of the access-requiring action, reverting immediately after.
flowchart LR
A["Accept connection"] --> B["Capture peer fd"]
B --> C["Service-identity work"]
C --> D["Install token, one action, revert"]
D --> E["More service-identity work"]
E --> F["Install token, one action, revert"]
F --> G["..."]
G --> H["Close token fd"]
The sequence:
- At accept, the server calls
kacs_open_peer_token(fd)to capture the client's identity as a token fd. The fd is stored in the request context — request struct, goroutine-local, whatever the runtime provides. - Most of the request is processed as the service's own identity. Decoding, dispatching, internal bookkeeping, response framing — none of these need the client.
- When the code reaches the one operation that requires the client — opening a user-controlled file, reading the user's home directory, anything where the access decision must be the client's, not the server's — it installs the impersonation token on the current OS thread (
KACS_IOC_IMPERSONATE), does the single operation, and callskacs_revertimmediately. - The request continues at service identity. Further per-request impersonations follow the same install-do-revert pattern.
- At end of request, the captured token fd is closed.
This pattern has two real advantages over the canonical flow:
- It is safe under multiplexed threading. The impersonation is installed and reverted within a tight, synchronous block of code that does not yield to the runtime, so the runtime cannot move the work to another OS thread mid-impersonation. The token fd in the request context travels with the request regardless of which thread is currently executing it; only the install-do-revert window actually pins a thread.
- It minimises the time spent impersonated. Bugs where the wrong code runs as the wrong identity — a logging call between impersonate and the real work, an error path that forgets to revert, an unrelated background task pre-empting on the same thread — are bounded by the same tight block. Most of the request is unambiguously the service's identity; only the access-requiring call is the client's.
The cost is a small amount of boilerplate at every impersonation point. The performance cost is negligible: install and revert are pure in-kernel operations with no allocation and no IPC.
If you are writing a new service for Peios in a modern runtime, this is the pattern to start from. The canonical flow above is the simple-case reference; just-in-time is the realistic default.
Why not just run the server as the user? #
The reason impersonation exists rather than "spawn one server process per user" is the cost. Long-lived services that handle many users — a file server, a database, the registry daemon — would otherwise need one process per concurrent session. Impersonation lets a single long-lived service process handle requests for arbitrary users by swapping identities per thread, paying only the cost of an in-kernel token install per request.
The trade-off is that the server has to be careful. Code paths that touch user-controlled state are expected to be running impersonated; code paths that touch service-internal state (its own configuration, its own logs) are not. Mixing these up is the most common bug category in services written for this model: a request handler that opens a user-controlled path while still running as the service account, or a maintenance routine that touches the service's own state while still impersonating the last client.
The pattern that minimises this is to make impersonation the default state of a request thread and revert only at well-defined boundaries (logging, internal bookkeeping, between requests).
What impersonation does not do #
A few things impersonation looks like at a glance but is not:
- It does not invent new authority. The impersonation token carries the client's privileges, integrity (capped at the server's own — see The two-gate model), groups, and SIDs intact, and the impersonating thread can exercise any of them on the client's behalf. What impersonation does not do is grant more than the client had, raise integrity above the server's ceiling, or change the binary's PIP.
- It does not give the server arbitrary identity. The server can only assume identities the kernel has handed it — through a captured peer token, or through a token fd the server already possessed by some other path. There is no API to fabricate an impersonation token from a SID string.
- It does not grant inherent access. Impersonating a user is not a master key to the user's data. The user's home directory, registry keys, and in-flight tokens are reachable if and only if the user themselves has access — the access check still runs to decide. Impersonation is the mechanism for evaluating that check as the user; it is not the access itself.
- It does not survive exec. A thread that execs while impersonating has its impersonation token released before the new program runs. Impersonation is intra-program state; the new binary cannot inherit it.
Where to start #
If you want to understand the four impersonation levels — Anonymous, Identification, Impersonation, Delegation — and what each one permits, read Impersonation levels.
If you want the rules that decide what level a server actually ends up with — the identity gate, the integrity ceiling, and the silent downgrade behaviour — read The two-gate model.
If you want the concrete mechanics — peer token capture from sockets, the difference between kacs_impersonate_peer and kacs_open_peer_token, and the explicit-fd variant — read Peer tokens and capture.
Impersonation levels
Peios / Peios Security Fundamentals / Impersonation
Every impersonation token carries an impersonation level — one of four values, ordered from least to most permissive. The level is the answer to "how much may a server do with this identity?". Each level admits all the operations of the levels below it and additionally allows specific extra things.
There is one rule worth pinning before the catalog: the level is set by the client, not the server. A client calls kacs_set_impersonation_level on the socket before connect() to declare the maximum level a server may use. The kernel records the level on the socket. When the server later impersonates the peer, the level cannot exceed what the client set. A server cannot ask for a higher level; it can only end up at the level the client granted or lower.
The four levels #
The numeric values are also catalogued in Other constants.
| Value | Name | What a server may do |
|---|---|---|
| 0 | Anonymous | Nothing identity-related. The impersonation token has the Anonymous SID as its user; the server learns nothing about the actual client. |
| 1 | Identification | Inspect the client's identity. The server may read the client's SIDs, groups, integrity level, and privileges — but the token must not be used for AccessCheck. Any access check using an Identification-level token is denied immediately, no matter what the DACL says. |
| 2 | Impersonation | Act as the client for all local operations. This is the default and the level most server-to-client code paths assume. |
| 3 | Delegation | Identical to Impersonation locally, plus the client's credentials may be forwarded to a remote machine over Kerberos. The kernel only records that Delegation was granted; the actual forwarding is authd's responsibility. |
The default — what a socket carries if the client never sets a level — is Impersonation. This default exists because the vast majority of clients want their server to be able to act on their behalf; making the no-action case the conservative one would push every client into boilerplate.
Anonymous #
The least useful level, and not as common as you might expect. An Anonymous impersonation token has:
- A user SID of
S-1-5-7(the well-known Anonymous SID). - The Everyone group, but not Authenticated Users.
- Untrusted integrity.
- No privileges.
A server impersonating an Anonymous client effectively has no identity at all. It can act, but every access check is evaluated as Anonymous — which usually fails because almost every interesting object's DACL grants nothing to Everyone-but-not-Authenticated-Users.
You will mostly see Anonymous in two cases:
- A client explicitly opted out of identity disclosure. Some protocols want the server to act on a request without knowing who issued it. The client calls
kacs_set_impersonation_level(ANONYMOUS)on its socket; the server impersonates and gets Anonymous. - A connection that was never authenticated. A network peer that connected without going through any auth step is, from the server's point of view, Anonymous. The token reflects this honestly.
Identification #
The most misleading level. Identification gives the server enough of the client's token to inspect identity but explicitly forbids using it for access decisions. AccessCheck with an Identification-level token returns ACCESS_DENIED immediately, before reading the DACL.
The intended use is for servers that need to know who is asking — for audit logging, for routing decisions, for displaying a username — without actually wanting to act as them. A web server that logs which user made each request but always reads files as itself wants Identification.
The pitfall: a server that thinks it is doing Impersonation but the client set the level to Identification finds every file open returning ACCESS_DENIED for reasons that look nothing like an identity problem. The DACL is fine; the user has rights to the file; the server's primary identity has rights to the file. Yet every access fails. The cause is almost always that the client connected at Identification level and the server is supposed to be inspecting, not acting.
The fix is at the client end. If your server has to act on behalf of the user, the client must connect at Impersonation level (or accept the default — which is already Impersonation).
There is one specific construction that always produces an Identification-level token: KACS_IOC_GET_LINKED_TOKEN called without SeTcbPrivilege returns an Identification-level clone of the partner token. The clone is for inspection only — it cannot be installed and would fail every access check if it were. See Elevation and linked tokens.
Impersonation #
The level you will use almost all of the time. An Impersonation-level token can act as the client for any local operation:
- Open files as the client.
- Read and modify registry keys as the client.
- Connect to other local services as the client (and have those services impersonate the same client — see "Identity cascading" in Peer tokens and capture).
- Be the subject of any AccessCheck call.
What an Impersonation-level token cannot do is cross the machine boundary with the client's identity. If the impersonating service makes a network request that requires authentication, that request goes out using the service's credentials, not the client's. The client's identity stays on this machine.
That last point is the dividing line between Impersonation and Delegation.
Delegation #
Delegation extends Impersonation to remote calls. A server at Delegation level can act as the client locally and forward the client's identity to a remote machine over Kerberos. The remote service receives a token that represents the original client, not the intermediary.
The mechanism is entirely in authd. KACS records that the impersonation token was granted Delegation; the kernel does nothing further. When the impersonating server makes an outbound network request, authd consults the token's level. If it sees Delegation, authd performs the Kerberos credential forwarding (constrained or unconstrained, depending on the domain policy). If it sees only Impersonation, authd authenticates the outbound request as the server's own identity.
The split — KACS records the flag, authd acts on it — keeps the kernel out of the Kerberos business. It also means Delegation is meaningful only on domain-joined machines where authd has Kerberos configured. On a standalone machine, Delegation is recorded but has no effect, since there are no remote services to forward to.
What sets the level #
Three things determine the level a server actually ends up with:
- The client's request, set on the socket before connect via
kacs_set_impersonation_level. Default is Impersonation if the client says nothing. - The two-gate model, applied at impersonation time: the identity gate and the integrity ceiling. Either can cause the granted level to be silently downgraded. See The two-gate model.
- The transport. Some Unix-socket variants — SOCK_DGRAM, socketpair, pre-existing pipes — do not carry a peer token. Servers using these transports cannot use
kacs_impersonate_peerand must use the explicit-fd ioctl instead. See Peer tokens and capture.
The first sets the maximum. The second can lower it. The third is about the mechanism for getting the token at all.
Inspecting the granted level #
A server that has just impersonated can read its own effective token to check what level it actually got. Reading the impersonation_level field of the effective token (via KACS_IOC_QUERY on the thread's token fd) returns the level in effect.
This is worth doing at runtime if your code branches on level. The "client asked for Impersonation, but the integrity ceiling silently downgraded to Identification" case is silent by design — the impersonation call succeeds and the work proceeds, but every access check on the downgraded token will fail. Code that wants to detect the downgrade before doing access-requiring work should query the level first and skip work for which the level is insufficient.
Where to go next #
For how the kernel decides the level a server actually ends up with — and why the downgrade is silent — read The two-gate model.
For how a server gets hold of a client's identity in the first place, read Peer tokens and capture.
The two-gate model
Peios / Peios Security Fundamentals / Impersonation
When a server impersonates a client, it does not automatically get the level the client granted on the socket. The kernel runs two independent gates first. Each gate can lower the effective level. The granted level is the minimum the two gates permit.
The two gates are:
- The identity gate — is the server allowed to impersonate this particular user's identity?
- The integrity ceiling — may the impersonation token's integrity level exceed the server's own?
Each gate is checked independently. Failing either one is silent: the impersonation call still succeeds, the level is silently capped, and the server only finds out by inspecting the resulting token. The kernel deliberately does not raise an error, because the requested level is the maximum — any lower level is a valid outcome.
There is one case where the kernel refuses outright with -EPERM rather than downgrading. That case is covered below.
Gate 1: identity #
The identity gate asks: does the server have authority to impersonate this specific user? It has two ways to pass:
- The server's primary token has the same user SID as the client. A process can always impersonate its own user, regardless of privileges.
- The server's primary token holds
SeImpersonatePrivilege. The privilege explicitly grants the right to assume any user's identity.
If neither is true, the identity gate fails. The token still gets installed, but the effective level is capped at Identification — the server can inspect the client's identity but cannot use the token for any access check.
The split is deliberate. A service whose job is handling user requests — a file share, a database, the registry daemon — holds SeImpersonatePrivilege and can impersonate any user. A process running as user X can act as user X without any special privilege. Code that holds neither — a random user-mode process trying to install some other user's token — is restricted to inspection only.
SeImpersonatePrivilege is one of the few privileges that authd grants liberally to services in the role-policy assignment. Almost every service that handles user requests has it. The privilege is what distinguishes "code authorised to act as users" from "code that happened to receive a token fd".
Gate 2: integrity ceiling #
The integrity ceiling asks: does the impersonation token's integrity level exceed the server's own primary token's integrity level?
If it does — the client has High integrity, the server has Medium — the effective impersonation level is silently capped at Identification. The token still installs, and the client's integrity label may be preserved as inert identity metadata, but the token cannot pass any access check.
The motivation is concrete. Without the ceiling, a Medium-integrity service that captured a High-integrity client's token would suddenly be operating at High integrity for the duration of the request. That breaks the integrity model — a process's effective ceiling should be bounded by what it would have had on its own.
The ceiling is always enforced, and it caps the level, not the token's integrity label. SeImpersonatePrivilege does not bypass it. There is no privilege that does. A service that needs to act on behalf of a High-integrity client must itself run at High integrity.
Composition: the minimum permitted #
The two gates are independent. Each one decides what level it permits:
| Identity gate result | Integrity ceiling result | Granted level |
|---|---|---|
| Pass at full level | Token's integrity is at or below server's | The level the client granted on the socket. |
| Pass at full level | Token's integrity exceeds server's | Identification. The ceiling downgrades the level; the token installs but no AccessCheck will pass. |
| Fail (no same-SID, no SeImpersonate) | Token's integrity is at or below server's | Identification. Token installs, server can inspect, but no AccessCheck will pass. |
| Fail | Token's integrity exceeds server's | Identification. Both gates independently cap to the same level. |
The granted level is the minimum the two gates permit. Neither gate fails the operation; both can downgrade it. The downgrade is silent.
The silent downgrade #
This is the consequence worth memorising: impersonation never errors on policy failure. It silently downgrades.
A server that calls kacs_impersonate_peer(fd) and gets a successful return does not know whether the resulting token has the level it expected. Both an Impersonation-level token and an Identification-level token can come out of the same call, with no error in either case. The only way to tell is to query the token after install.
Code that wants to detect a downgrade has to do something like:
- Call
kacs_impersonate_peer(fd)(or the explicit-fd variant). - Open the thread's effective token.
- Read
impersonation_levelviaKACS_IOC_QUERY. - Branch on the result: proceed if Impersonation or Delegation, log-and-skip if Identification or Anonymous.
The reason for the silent design is that the gates are bounded by policy and the policy is the operator's decision. A service may legitimately want to handle requests from clients regardless of whether it can fully impersonate them — recording who asked for what even when it cannot act as them is useful. Making the downgrade an error would force every server to either bypass the failure or refuse to handle the request, neither of which is the right default.
The one hard-deny case #
There is exactly one situation where impersonation fails with an error rather than downgrading: a restricted token attempting to impersonate an unrestricted token of the same user.
The kernel refuses with -EPERM. The impersonation does not happen, the thread does not get a new token, the call returns an error.
The reason is sandbox-escape prevention. A process running on a restricted token — a sandbox process, an anti-malware quarantine — is supposed to have narrowed identity. If that process could impersonate an unrestricted version of the same user, the restriction would be trivially escapable: capture any token fd belonging to the same user (which the sandbox could obtain via socket peer capture or other means), impersonate, and suddenly the sandbox is running with full user identity.
The hard-deny applies specifically to the same-user case. A restricted process can still impersonate a different user's token, subject to the normal gates. It just cannot use impersonation as a way to "promote" itself back to the unrestricted version of its own identity.
This is the only impersonation case that errors. Every other policy failure is silent.
Double impersonation #
A thread that is already impersonating can install a second impersonation token. The kernel handles this by silently reverting the first impersonation before installing the second — there is no nesting.
The gates are evaluated against the primary token, not the current impersonation. Whatever the thread was previously impersonating does not affect what it can impersonate next. The primary's identity and privileges (notably SeImpersonatePrivilege) are what the kernel reads to decide both gates.
When the gates do not apply #
The gates fire on impersonation install. They do not fire on:
- Reverting.
kacs_revertalways succeeds and does not consult the gates. - Reading a token.
kacs_open_peer_tokenorkacs_open_thread_tokenreturns a token fd without impersonating; the gates only run when the token is actually installed on a thread. - Process token operations. Opening another process's primary token (
kacs_open_process_token) is governed by the process SD and PIP dominance, not the impersonation gates.
The gates are specifically about installing a foreign identity on a thread. Reading information about a token is a different operation governed by different checks.
The principle behind the model #
The two-gate model is the kernel's enforcement of one rule: a thread can never end up at a higher effective trust level than it could have reached on its own.
The identity gate makes sure the impersonation has authority behind it: either the same user, or the explicit SeImpersonatePrivilege that says "this service handles users by design". The integrity ceiling makes sure the impersonation cannot raise the server's effective integrity. Together they bound impersonation to what the server's own primary token would have permitted.
Everything else is a consequence. The silent downgrade exists because the requested level is a maximum, not a promise. The restricted-token hard-deny exists because that one case is the only way a same-user impersonation could increase authority rather than decrease it. The PIP exclusion (covered in Process integrity protection) is the same rule applied to the binary's trust label: PIP is a property of the binary, not of an identity, and impersonation does not change which binary is running.
Where to go next #
For the capture mechanism the gates run against — how a client's identity gets onto a socket and into the server's hands — read Peer tokens and capture.
For the restricted tokens behind the one hard-deny case, read Restricted and write-restricted tokens.
Peer tokens and capture
Peios / Peios Security Fundamentals / Impersonation
When a client connects to a server over a connected Unix socket, the kernel captures the client's identity onto the socket at connect time. The server can later impersonate that identity by calling kacs_impersonate_peer(fd) on the connection — no further negotiation, no credential exchange, no token transfer in the message stream. The token is held by the kernel and tagged to the socket from the moment of connect.
This is the primary way servers acquire impersonation tokens. Almost every local IPC in Peios — registry access, service-to-service calls, user-to-daemon requests — goes through it. The model is simple enough that most services need to know only one syscall (kacs_impersonate_peer) and one cleanup call (kacs_revert).
This page covers the capture mechanism, the two different syscalls for using a captured peer token, the transports that do and do not carry peer tokens, and how identity cascades when a service that is itself impersonating connects to a third service.
Capture at connect time #
flowchart LR
A["Client thread, primary token T_C"] -->|"kacs_set_impersonation_level (optional)"| B["Socket"]
A -->|connect| C["Server socket"]
B -.->|captured at connect| D["Peer token on server side, derived from T_C at the client-requested level"]
When the client calls connect() on a Unix stream or seqpacket socket, the kernel:
- Reads the client's effective token (impersonation if set, primary otherwise).
- Reads the impersonation level the client requested on the socket (default Impersonation).
- Constructs a peer token derived from the client's token, at the requested level.
- Attaches the peer token to the server-side socket.
The peer token is held by the kernel for the life of the socket. The client's identity travels into the kernel at connect, not at impersonate. By the time the server calls kacs_impersonate_peer, the work of capturing identity is already done.
This timing matters in one specific way: the captured identity is the client's identity at the moment of connect. If the client changes identity afterwards (impersonates a third party, drops privileges, anything), the change does not affect the peer token on the existing connection. The peer token is a snapshot. Servers that want to track changing client identity across a long-lived connection need to renegotiate (typically by having the client reconnect).
Two ways to use a peer token #
The kernel exposes two operations on a peer token:
kacs_impersonate_peer(fd)— the combined operation. Captures the peer's identity from the socket and installs it on the calling thread, at the level the two-gate model permits. The most common path.kacs_open_peer_token(fd)— the inspect-and-store operation. Returns a token fd to the peer's identity without installing it. The fd carriesTOKEN_QUERY | TOKEN_IMPERSONATEaccess — enough to read the token and to install it later viaKACS_IOC_IMPERSONATE, but not enough to duplicate or adjust it.
The two operations exist because servers have different needs:
- Direct request handling. A request thread that wants to impersonate, do work, and revert all in one tight synchronous block calls
kacs_impersonate_peerand thenkacs_revert. There is no reason to hold the token fd around. - Just-in-time impersonation. A server that wants to keep the client's identity available across a request but only install it at the moment of each access-requiring action — the just-in-time pattern covered on the overview page — calls
kacs_open_peer_tokento capture the token fd at request start, stores it in the request context, and installs it on the current OS thread (KACS_IOC_IMPERSONATE) just before each action that needs the client's identity, reverting immediately after. This is the right pattern for any server using a multiplexed runtime (Go, Rust async, Java virtual threads, Node), and is the recommended default for new services. - Deferred work across threads. A request handler that hands off work to a thread pool cannot reasonably keep its own thread impersonating while another thread does the work — impersonation is per-thread. The same
kacs_open_peer_tokenmechanism lets it capture the token fd, pass it to the worker, and have the worker install it before doing the access-requiring operation. - Inspection without action. A logging or audit thread that wants to record the client's identity without doing any work as them calls
kacs_open_peer_token, queries the token viaKACS_IOC_QUERY, and closes the fd. No impersonation is ever installed.
The "capture once, install per action" pattern is the main reason for separating the two operations. It is what makes safe impersonation possible under M:N threading and what bounds the time a thread is actually impersonated to the smallest window that does the work.
kacs_revert and the explicit-fd variant #
A thread reverts impersonation with kacs_revert(). It always succeeds. It drops the impersonation token reference and restores the primary as the effective identity.
There is also KACS_IOC_IMPERSONATE — an ioctl on a token fd, not on a socket. This is the explicit-fd variant. The caller passes the fd of a token they have obtained by some other means (DuplicateToken, kacs_open_peer_token, etc.) and the kernel installs it on the calling thread, running the two-gate model as usual.
kacs_impersonate_peer is essentially kacs_open_peer_token followed by KACS_IOC_IMPERSONATE, fused for the common case where you do not need the token fd to outlive the impersonate-and-revert cycle.
Transports that carry peer tokens #
Peer token capture works on Unix sockets that have a real connect step. Specifically:
| Transport | Peer token? |
|---|---|
SOCK_STREAM Unix socket | Yes. Captured at connect. |
SOCK_SEQPACKET Unix socket | Yes. Captured at connect. |
SOCK_DGRAM Unix socket | No. No connect step; no point at which to capture. |
socketpair(2) | No. The two ends share an origin; there is no "client" and "server". |
Pipes (pipe(2), named FIFOs) | No. No socket framework. |
| TCP sockets | No. Peer is potentially remote; KACS does not capture network identities. |
Servers using a transport that does not carry a peer token cannot use kacs_impersonate_peer. They have to obtain a token fd by some other path and use KACS_IOC_IMPERSONATE to install it. Common patterns:
- Token fd passed over the connection. A datagram protocol can include a token fd in an
SCM_RIGHTSmessage; the receiver gets the fd and impersonates from it. The token fd has whatever access the sender opened it with, bounded by what the sender held. - Out-of-band capture. A service that knows the peer's PID can call
kacs_open_process_token(pidfd)to get the peer's primary token (subject to the process SD and PIP dominance checks). Less common; usually only the loopback-like patterns inside the TCB work this way.
The lack of peer tokens on datagram and pipe transports is intentional, not an oversight. Those transports are fundamentally connection-less or pre-existing; capturing a "peer" identity that may not exist is ill-defined.
Identity cascading #
A service that is impersonating client A may itself need to call a third service. When it does, the connection it opens carries A's identity, not the service's own.
flowchart LR
A["Client A"] -->|connect| B["Server S1, impersonating A"]
B -->|connect to S2 while impersonating| C["Server S2"]
C -.->|peer token sees A, not S1| D["S2 can impersonate A"]
The mechanism: the impersonation token is the thread's effective token, and the effective token is what the kernel reads when capturing peer identity at connect. So when S1, while impersonating A, calls connect() on a socket to S2, the kernel captures A's identity onto S2's side of the socket. S2 can then kacs_impersonate_peer and get A.
This cascading is automatic and is what makes the local-IPC ecosystem work cleanly. A user makes one request to a service, and that service makes downstream requests to other services on the user's behalf — registry, file system, audit logging — and each of those downstream services sees the user as the peer. No explicit token forwarding is required.
The cascading is bounded by the impersonation level the original client granted. If A connected at Impersonation level, S1 captures A at Impersonation; when S1 connects to S2, the captured identity on S2's side is bounded by what S1's effective token currently is — which is A at Impersonation. The level does not get re-extended by cascading.
The same mechanism reaches the network boundary only at Delegation. If A connected at Delegation level, the captured identity on cascaded local connections is still A at Delegation, and outbound network calls from S1 carry A's Kerberos credentials (via authd; KACS only records the flag). Without Delegation, network calls from S1 go out as S1, not as A.
What the server has to do #
There are two server-side shapes, depending on which pattern (canonical synchronous or just-in-time) the server is using. Both start at the same point — the kernel has captured the client's peer token onto the socket at connect time — and diverge after accept.
Canonical synchronous flow (one OS thread, one request, start to finish):
- Accept the connection. Standard
accept()on the Unix socket. The kernel has already captured the client's peer token onto this socket. - Impersonate the peer. Call
kacs_impersonate_peer(fd). The kernel runs the two-gate model and installs the resulting token. - (Optional) Inspect the granted level. Read the thread's effective token's
impersonation_level. If it is below what you expected, the client's request, the identity gate, or the integrity ceiling has lowered it. - Do the work. Every access check on this thread is now against the impersonation token.
- Revert. Call
kacs_revert(). The thread is back to its primary identity. - Handle the next request. Either on the same connection (loop back to step 2 if the client may re-impersonate per request) or on the next connection.
Just-in-time flow (the right shape for any modern runtime — Go, Rust async, etc. — and the recommended default):
- Accept the connection. As above.
- Capture the peer token fd. Call
kacs_open_peer_token(fd)and store the returned token fd in the request context. The fd carriesTOKEN_QUERY | TOKEN_IMPERSONATE. - (Optional) Inspect the captured token. Query the captured fd to verify the identity and level before doing any work.
- Do most of the request as the service. Decoding, dispatch, internal bookkeeping, response framing — all run as the service's own identity.
- For each access-requiring action, install the captured token on the current OS thread (
KACS_IOC_IMPERSONATEon the stored fd), do the single action, callkacs_revert()immediately. Keep the impersonation window as tight as possible. - Close the captured fd at end of request.
A server that handles concurrent connections per thread runs whichever flow it uses in each thread, in parallel. The impersonation state is strictly per-thread; nothing shared.
Common mistakes #
A few patterns that bite first-time service authors:
- Forgetting to revert. A thread that finishes a request without calling
kacs_revertcontinues to impersonate the previous client. The next request handled on that thread uses the wrong identity. Makekacs_revertpart of your between-requests cleanup, unconditionally. - Reverting too early. A thread that reverts before all of the work is done — perhaps because an inner function is doing cleanup that runs as the service identity — will fail access checks on the parts that needed to remain impersonated. Keep the impersonation scope wide enough to cover the whole user-facing operation.
- Holding a peer token fd across exec. Token fds are file descriptors and survive exec by default (unless O_CLOEXEC was set). But the impersonation state of the thread does not survive exec — it is reverted automatically. If your service execs another binary, the new binary starts with no impersonation, even if the old code was impersonating.
- Assuming the level you asked for is the level you got. The two-gate model can silently downgrade. Code that branches on level should query, not assume.
Where to go next #
For what the impersonated identity is checked against once the server starts acting as the client, read Security descriptors.
To drive impersonation from a shell — impersonate a peer, revert, inspect the effective token — read The token command.
Security descriptors
Peios / Peios Security Fundamentals / Security Descriptors
A security descriptor (often just SD) is the policy attached to every protected object in Peios. Every file, every registry key, every IPC socket, every running process, every token — all of them carry an SD that defines the security policy for that one object. Where the token is the "who" of an access decision, the SD is the "what to check against".
A security descriptor has four components, each with its own role, its own evaluation rules, and its own pitfalls. This topic covers each in turn.
The four components #
Every SD has exactly four parts:
| Component | What it does |
|---|---|
| Owner | A single SID identifying who owns the object. The owner has implicit READ_CONTROL and WRITE_DAC rights (with one exception, covered in Ownership). |
| Primary group | A single SID. Used when CREATOR_GROUP placeholder ACEs are substituted during inheritance. Rarely directly meaningful. |
| DACL (Discretionary Access Control List) | The ordered list of ACEs that decides who can do what to the object. This is the part most people mean when they say "the permissions on a file". Controlled by the owner via WRITE_DAC. |
| SACL (System Access Control List) | The list of system-level policy ACEs — audit rules, the object's integrity label, resource attributes, central access policy references, PIP trust labels. Controlled by anyone with ACCESS_SYSTEM_SECURITY (which is gated by SeSecurityPrivilege). |
A given SD can have all four components or some subset. An SD without a DACL is not the same as an SD with an empty DACL — see DACL evaluation for the rule. An SD without an owner is malformed; the access check rejects it.
What an SD does not contain #
Worth knowing what is not in an SD, because these things are often mistakenly attributed to it:
- The object's content or data. SDs say nothing about what the object is, only about who can use it.
- The object's type. Whether you are dealing with a file, a registry key, or a process is the object manager's concern. The SD is the same shape regardless; only the access rights it grants are object-type-specific (because the GenericMapping for each type is different).
- The token of the principal who is trying to access the object. That comes from the caller, not the SD. An SD is policy; tokens are subjects; the access check matches them up.
- Anything about the parent object. Inheritance is computed at creation time — a child object's SD is its own complete value, not a delegation to the parent. See Inheritance for the eager-evaluation model.
These boundaries matter because the SD's job is small and well-defined. Everything else about an object lives somewhere else: the data is in the data path, the type is in the object manager, the access decision is in the access check.
Where SDs live #
Different object types store SDs in different places, but the format and meaning are the same.
| Object type | Storage |
|---|---|
| Files and directories | An xattr (security.peios.sd, or system.ntfs_security on NTFS volumes) on the inode. |
| Registry keys | Stored by the registry source. |
| Processes | A field on the PSB (process security block). |
| Tokens | A field on the token object itself. |
| Abstract Unix sockets | A field on the socket LSM blob, stamped from the binding thread's effective token at bind(). |
| IPC objects | A field on the object's kernel structure. |
The binary format — the self-relative layout — is uniform. An SD that was written for a file can be read by a tool that uses the same parser as for a registry key. This consistency is what lets system tools work generically across object types.
Note one important rule: reading or writing the SD xattr directly is denied unconditionally. Even with CAP_DAC_OVERRIDE. All SD access must go through the appropriate KACS API (kacs_get_sd, kacs_set_sd, or the equivalent registry-side APIs). The xattr exists so the SD travels with the file across normal filesystem operations (copy, backup, restore by tools that understand xattrs), but the security boundary is the API, not the xattr layer.
The cost-of-modification asymmetry #
A useful intuition: modifying different parts of an SD requires different rights.
| To change | You need |
|---|---|
| The DACL | WRITE_DAC on the object (the owner always has this unless suppressed by OWNER RIGHTS). |
| The owner | WRITE_OWNER on the object, plus a restriction on which SIDs you can name as the new owner (covered in Ownership). |
| The SACL | ACCESS_SYSTEM_SECURITY — granted by SeSecurityPrivilege rather than by the object's DACL. The DACL never grants this right. |
This asymmetry is deliberate. The DACL is what users control to manage their own data. Ownership is a stronger lever — taking ownership is a deliberate administrative act. The SACL is a system-level concern, gated by a privilege held by administrators and audit systems.
How an SD gets created #
There are three ways an object ends up with its SD:
- Inherited from a parent. The most common path: when a child object is created, its SD is computed by combining the parent's inheritable ACEs with the creating principal's defaults. The result is stored on the child as a complete SD. The parent is not consulted again at access-check time. See Inheritance.
- Explicitly supplied at creation. The creator passes an SD as a parameter (to
kacs_openwith a create disposition, to a registry key create, etc.). This SD is taken as-is, subject to the rules about what fields the creator may set (the creator must be entitled to name the owner, for instance). - Synthesised by the kernel. For objects whose storage layer cannot hold an explicit SD (an abstract socket bound by a process that did not provide one, a file on a mount with
synthesize_ephemeralpolicy and no parent inheritance), the kernel constructs a default SD from the creating principal's token (default_dacl,owner_sid_index,primary_group_index). The synthesis path is per-object-type but the result is a normal SD.
Once an SD is in place, it is the authoritative policy for the object. Modifying it after creation is a separate operation governed by kacs_set_sd and its access requirements.
The SD as an evaluation target #
When the access check runs on an SD, it is not reading every field. The pipeline does specific things with specific parts of the SD in a specific order. The full algorithm lives in Access decisions; a brief sketch for orientation:
- The owner field is consulted to determine implicit rights (and whether they have been suppressed by OWNER RIGHTS).
- The SACL is scanned for mandatory integrity labels and PIP trust labels — these gate the access independently of the DACL.
- The DACL is walked, ACE by ACE, in order, applying first-writer-wins.
- The SACL is scanned again for audit and alarm ACEs that should fire as a result of the access attempt.
Each step has its own page in this topic. The point of mentioning the order here is so that as you read the deeper pages you can see where each component is consulted.
Where to start #
If you want the structural details — what an ACL looks like, what each ACE type means, what bits the access mask uses — read ACLs, ACEs, and access masks.
If you want to understand how a DACL actually decides "allowed" or "denied" — first-writer-wins, ACE ordering, the canonical order, MAXIMUM_ALLOWED — read DACL evaluation.
If you want to know how owner implicit rights work and why OWNER RIGHTS can suppress them, read Ownership and implicit rights.
If you want the inheritance model — how a child's SD is computed from its parent and creator, and why parent changes don't propagate to existing children — read Inheritance.
If you want conditional ACEs — the ABAC-style mechanism where access depends on token claims and resource attributes — read Conditional ACEs.
If you want the SACL specifically — audit, alarm, integrity labels, central access policy references, PIP trust labels — read The SACL.
To read and change a descriptor from a shell — owner, DACL, SACL, label, inheritance — read The sd command.
ACLs, ACEs, and access masks
Peios / Peios Security Fundamentals / Security Descriptors
A DACL and a SACL are both Access Control Lists — sequences of Access Control Entries in a defined order. The two lists have different jobs (the DACL decides access; the SACL records system-level policy and audit), but they share a structure. This page covers that structure, plus the catalog of ACE types and the layout of the 32-bit access mask that almost every ACE carries.
ACL structure #
An ACL is, conceptually, an ordered array of ACEs. The binary form is a short header — a revision byte, the total AclSize, and the AceCount — followed by the ACEs back-to-back; the byte-level layout is owned by the Security descriptors wire format.
The maximum ACL size is 64 KB — the AclSize field is a 16-bit value. An ACL is self-delimiting: a parser walks exactly AceCount ACEs starting after the header and stops when it has consumed AclSize bytes.
The two ACL revisions differ only in what ACE types they are allowed to contain. 0x02 is the basic revision; 0x04 additionally allows object ACEs (the type with property GUIDs) and callback ACEs (the type with conditional expressions). Parsers should accept whichever revision they see; writers should emit the minimum revision necessary for the ACEs they are including.
ACE structure #
Every ACE has a 4-byte header — a one-byte AceType, a one-byte AceFlags, and a two-byte AceSize (always a multiple of 4) — followed by a type-specific body. The body's shape depends on the type. The most common shapes:
- Single-SID body —
Mask (4 bytes) | SID (variable). Used by basic allow/deny/audit/alarm ACEs and by the integrity/scoped-policy/trust-label SACL types. - Object ACE body —
Mask (4) | Flags (4) | optional ObjectType GUID (16) | optional InheritedObjectType GUID (16) | SID (variable). The flags field controls whether each GUID is present. - Callback (conditional) body — same as the corresponding non-callback type, plus a trailing ApplicationData block holding the conditional expression bytecode. See Conditional ACEs.
The detailed byte-level layouts live in the Wire formats reference. For this page, what matters is that every ACE is self-describing — the parser reads the header, dispatches by type, and consumes exactly AceSize bytes.
The ACE type catalog #
There are twenty-one ACE types, split into three families: access-control, audit/alarm, and system-policy. Most SDs you will look at use only a small subset of these — typically ACCESS_ALLOWED, ACCESS_DENIED, SYSTEM_AUDIT, and SYSTEM_MANDATORY_LABEL. The numeric type values are catalogued in ACE types and flags; the tables here describe what each type is for.
Access-control ACE family #
These ACEs participate in the DACL walk.
| Type | Body | Use |
|---|---|---|
ACCESS_ALLOWED | Single SID | Grant the listed rights to the named principal. |
ACCESS_DENIED | Single SID | Deny the listed rights to the named principal. |
ACCESS_ALLOWED_COMPOUND | Reserved (not used in v0.20). | — |
ACCESS_ALLOWED_OBJECT | Object ACE | Grant rights, scoped by property GUID or inherited only by certain child object types. |
ACCESS_DENIED_OBJECT | Object ACE | Deny rights, same scoping. |
ACCESS_ALLOWED_CALLBACK | Single SID + expression | Grant rights only if the conditional expression evaluates TRUE. |
ACCESS_DENIED_CALLBACK | Single SID + expression | Deny rights when the expression evaluates TRUE or UNKNOWN. |
ACCESS_ALLOWED_CALLBACK_OBJECT | Object ACE + expression | Grant rights, conditionally, scoped by GUID. |
ACCESS_DENIED_CALLBACK_OBJECT | Object ACE + expression | Deny rights, conditionally, scoped by GUID. |
The most common ACEs you will write are ACCESS_ALLOWED and ACCESS_DENIED — plain "this principal gets these rights" or "this principal does not get these rights". The object variants matter when you are protecting directory-style objects with per-property permissions. The callback variants are how conditional expressions show up in ACEs.
Audit and alarm ACE family #
These ACEs sit in the SACL and decide when access produces an audit event.
| Type | Body | Use |
|---|---|---|
SYSTEM_AUDIT | Single SID | Emit an audit event when the named principal attempts the listed rights. Flags decide whether success, failure, or both. |
SYSTEM_AUDIT_OBJECT | Object ACE | Same, scoped by GUID. |
SYSTEM_AUDIT_CALLBACK | Single SID + expression | Audit conditionally. UNKNOWN result emits the event (fail-open for auditing). |
SYSTEM_AUDIT_CALLBACK_OBJECT | Object ACE + expression | Conditional, scoped by GUID. |
SYSTEM_ALARM | Single SID | Configure per-operation continuous audit on the open handle. Each matching operation produces an event. |
SYSTEM_ALARM_OBJECT | Object ACE | Same, scoped by GUID. |
SYSTEM_ALARM_CALLBACK | Single SID + expression | Conditional continuous audit. |
SYSTEM_ALARM_CALLBACK_OBJECT | Object ACE + expression | Conditional, scoped by GUID. |
The distinction between AUDIT and ALARM is important: AUDIT fires at the moment of handle creation (one event per access attempt), ALARM configures a per-operation mask on the open handle that fires on every subsequent operation. See The SACL and Auditing for the full story.
System-policy ACE family #
These ACEs sit in the SACL and carry policy other than audit.
| Type | Body | Use |
|---|---|---|
SYSTEM_MANDATORY_LABEL | Integrity SID + mask | The object's mandatory integrity label and the policy bits that govern who can write/read/execute up. |
SYSTEM_RESOURCE_ATTRIBUTE | Everyone SID + claim entry | A name-value attribute on the object, referenceable as @Resource.<name> in conditional expressions. |
SYSTEM_SCOPED_POLICY_ID | Policy SID | A reference to a central access policy that should be evaluated alongside the object's own DACL. |
SYSTEM_PROCESS_TRUST_LABEL | PIP SID + mask | The object's PIP trust label and the explicit allowed mask for non-dominant callers. |
These four are how the SACL extends the access decision beyond what the DACL alone can express. Each has its own evaluation rules (covered in the relevant topics — MIC under Access decisions, resource attributes under Resource attributes, CAAP under Central access policies, PIP under Process integrity protection).
ACE flags #
The AceFlags field is a bitmask. Most flags control inheritance — whether and how an ACE on a parent object propagates to children. Two flags control audit firing on SYSTEM_AUDIT and SYSTEM_ALARM ACEs. (Bit values: ACE types and flags.)
| Flag | Effect |
|---|---|
OBJECT_INHERIT_ACE | The ACE inherits to non-container child objects (files, not directories). |
CONTAINER_INHERIT_ACE | The ACE inherits to container child objects (directories). |
NO_PROPAGATE_INHERIT_ACE | The ACE is inherited by direct children but its OBJECT_INHERIT_ACE and CONTAINER_INHERIT_ACE flags are cleared in the inherited copy — it stops propagating after one level. |
INHERIT_ONLY_ACE | The ACE is for inheritance only. The access check skips it; it exists only to be copied to children. |
INHERITED_ACE | Set on ACEs that were created by inheritance, not by an explicit add. Tools use this flag to distinguish "this came from the parent" from "the user set this explicitly". |
SUCCESSFUL_ACCESS_ACE_FLAG | (Audit/alarm only) Fire on successful access. |
FAILED_ACCESS_ACE_FLAG | (Audit/alarm only) Fire on denied access. |
The inheritance flags compose in non-obvious ways. Combinations you will see often:
| Flags | Effect |
|---|---|
CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE (CI | OI) | Inherit to all descendants (files and directories), recursively. |
CONTAINER_INHERIT_ACE (CI) | Inherit to descendant containers only. |
OBJECT_INHERIT_ACE (OI) | Inherit to descendant non-containers only. |
CI | OI | INHERIT_ONLY_ACE | Apply to descendants but not to this object. |
CI | OI | NO_PROPAGATE_INHERIT_ACE | Apply to immediate children only, not grandchildren. |
The full inheritance machinery is on Inheritance.
The access mask #
Every ACE that participates in the DACL walk, and every audit/alarm ACE, carries a 32-bit access mask. The mask uses four regions, partitioned by bit position.
| Region | Bits | Purpose |
|---|---|---|
| Object-specific rights | 0–15 | 16 bits whose meaning depends on the object type. For a file: FILE_READ_DATA, FILE_WRITE_DATA, etc. For a registry key: KEY_QUERY_VALUE, KEY_SET_VALUE, etc. For a process: PROCESS_TERMINATE, PROCESS_VM_READ, etc. |
| Standard rights | 16–20 | 5 bits shared across all object types: DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE. |
| Reserved | 21–23 | Must be zero. |
| Special rights | 24–25 | ACCESS_SYSTEM_SECURITY — gates SACL read/write. MAXIMUM_ALLOWED — a request flag, not a real right. |
| Reserved | 26–27 | Must be zero. |
| Generic rights | 28–31 | GENERIC_ALL, GENERIC_EXECUTE, GENERIC_WRITE, GENERIC_READ. Abstract — mapped to object-specific rights at evaluation time. |
The numeric values for every right live in Access mask bits; the mask's byte layout is in the SD wire format. A few things worth noting about the layout:
- The same bit means different things on different object types. Bit 0 is
FILE_READ_DATAon a file butKEY_QUERY_VALUEon a registry key. The object-specific portion of the mask is reusable across types because the object manager knows what kind of object it is. - Standard rights are uniform.
DELETEalways means delete the object;READ_CONTROLalways means read the SD;WRITE_DACalways means modify the DACL;WRITE_OWNERalways means change the owner. These bits work identically regardless of object type. - Generic rights are abstract.
GENERIC_READdoes not name a specific right. It is a placeholder that gets mapped to a concrete combination of object-specific and standard rights at the moment of evaluation, using the object type's GenericMapping table.
Generic mapping #
When an access check sees a generic right in either an ACE mask or a requested mask, it consults the object type's GenericMapping table to substitute concrete rights. The mapping is per-type:
| Object type | GENERIC_READ maps to |
|---|---|
| File | FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE |
| Token | TOKEN_QUERY | READ_CONTROL |
| Process | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | READ_CONTROL |
The full GenericMapping tables live in the Constants and catalogs reference. The key idea for now is: generic rights let an ACE author say "give the read permission for this thing" without knowing exactly which bits "read" means for this object type. The access check fills in the concrete bits at evaluation time.
This is also why an ACE that grants GENERIC_READ to a principal will produce different concrete grants depending on which object the ACE is on. The bit pattern in the ACE is the same; the meaning depends on where it lives.
MAXIMUM_ALLOWED is a request, not a right #
MAXIMUM_ALLOWED (bit 25) is special. It is not a right an ACE can grant — it is a marker the caller sets in a desired-access mask when asking "what is the maximum access I could be granted right now?". The access check sees the flag, evaluates the DACL fully, and returns every right the caller could be granted.
MAXIMUM_ALLOWED must not appear in an ACE. It is meaningless there.
Sizes and limits #
The size limits (maximum SD size, maximum ACL size, and the rest) are catalogued in Other constants. A practical SD on a typical object is far below all of them — a few hundred bytes at most. The limits exist for the pathological cases.
Where to go next #
For how the ACEs in a DACL actually decide "allowed" or "denied" — first-writer-wins, ordering, the null/empty distinction — read DACL evaluation.
For the full numeric catalog of ACE types, flags, and access-mask bits, see ACE types and flags.
To read and edit ACLs from a shell, read The sd command.
DACL evaluation
Peios / Peios Security Fundamentals / Security Descriptors
When the access check walks a DACL, it is answering one question, bit by bit: for each right the caller asked for, does this DACL grant or deny it? The mechanism is first-writer-wins — each bit is decided by the first ACE that mentions it, and once decided, no later ACE can override the decision. The walk is in the order ACEs appear in the DACL.
That rule is short. The consequences are not. The order of ACEs in a DACL is semantically load-bearing. A canonical order exists precisely so the rule produces the result you would expect. This page covers the walk, the canonical order, the special cases for null and empty DACLs, and what MAXIMUM_ALLOWED does to the algorithm.
The walk #
The access check starts with two state variables: decided (bits whose grant/deny status is now fixed) and granted (bits that have been granted). Both start empty. It walks the DACL from first ACE to last. For each ACE: skip if the ACE does not match the caller (wrong SID, INHERIT_ONLY_ACE set, conditional expression evaluates UNKNOWN-or-FALSE for allow / FALSE for deny). For an ACCESS_DENIED ACE that matches: any of its mask bits that are not yet in decided are added to decided and not added to granted (they are denied). For an ACCESS_ALLOWED ACE that matches: any of its mask bits that are not yet in decided are added to both decided and granted. After the walk, the access check has its answer: the bits in granted are the rights this DACL would give.
That paragraph is the whole algorithm for an ordinary DACL. Everything else on this page is either a special case (null/empty DACL, MAXIMUM_ALLOWED) or a rule about how to arrange ACEs so the walk produces the right answer.
First-writer-wins, illustrated #
Suppose a DACL contains:
ACCESS_ALLOWED Alice FILE_READ_DATA | FILE_WRITE_DATAACCESS_DENIED Alice FILE_WRITE_DATA
Alice asks for FILE_READ_DATA | FILE_WRITE_DATA. The walk:
- ACE 1 matches Alice.
FILE_READ_DATAandFILE_WRITE_DATAare not yet decided. Both are added todecidedand togranted. - ACE 2 matches Alice.
FILE_WRITE_DATAis already decided. The ACE has no effect.
Result: Alice gets both rights. The deny in ACE 2 came too late.
Now suppose the DACL is in the other order:
ACCESS_DENIED Alice FILE_WRITE_DATAACCESS_ALLOWED Alice FILE_READ_DATA | FILE_WRITE_DATA
The walk:
- ACE 1 matches Alice.
FILE_WRITE_DATAis not yet decided. It is added todecidedbut not togranted(denied). - ACE 2 matches Alice.
FILE_READ_DATAis not yet decided — it is added to both.FILE_WRITE_DATAis already decided. The ACE only takes effect onFILE_READ_DATA.
Result: Alice gets FILE_READ_DATA only. The deny in ACE 1 prevented the allow in ACE 2 from granting write.
The two DACLs contain exactly the same ACEs but produce different results. That is what is meant by "order matters".
The canonical ACE order #
Because ordering is load-bearing, Peios defines a canonical order that produces the result a reasonable reader would expect: explicit denies override explicit allows, and explicit ACEs override inherited ACEs.
| Position | ACE class |
|---|---|
| 1 | Explicit deny ACEs (set directly on the object). |
| 2 | Explicit allow ACEs. |
| 3 | Inherited deny ACEs. |
| 4 | Inherited allow ACEs. |
A DACL written in this order has the following properties:
- An explicit deny always overrides any allow (explicit or inherited) for the bits it covers.
- An explicit allow overrides any inherited rule.
- Inherited denies override inherited allows.
Tools that compose DACLs — a properties dialog, a system utility — are expected to maintain canonical order. The access check itself does not require it; the walk is the same regardless. But a DACL whose ACEs are out of canonical order produces results that may surprise the author. "I added a deny ACE and it had no effect" is almost always a canonical-order problem: the deny was placed after an allow that already decided the bits.
The four classes are distinguished by ACE type plus the INHERITED_ACE flag (0x10). Explicit ACEs have the flag clear; inherited ACEs have it set. The kernel sets the flag automatically when an ACE is created by inheritance.
NULL DACL vs empty DACL #
The two states look identical to a casual reader but mean opposite things.
| State | What the SD looks like | What the access check does |
|---|---|---|
| NULL DACL | SE_DACL_PRESENT flag is not set in the SD's control flags. | Grants every valid right. The walk is skipped. |
| Empty DACL | SE_DACL_PRESENT is set, the DACL exists, but contains zero ACEs. | Grants no rights (except owner implicit rights — see Ownership). The walk happens, decides nothing, and the result is an empty granted mask. |
The empty-DACL case is what you get when you explicitly remove every ACE from a DACL but keep the DACL itself. The intent is "no one has any rights to this object", and the access check honours it.
Tools that print or parse SDs must distinguish the two. Saying "the DACL is empty" without distinguishing between absent-DACL and zero-ACE-DACL is a recipe for misreading an object's policy.
ACE flags that affect the walk #
A few flags change whether or how an ACE participates:
| Flag | Effect on the walk |
|---|---|
INHERIT_ONLY_ACE (0x08) | The ACE is skipped entirely during the DACL walk. It exists only to be copied to children at inheritance time. |
INHERITED_ACE (0x10) | The ACE participates normally. The flag is used to identify inherited ACEs for canonical-ordering purposes, not to change evaluation. |
OBJECT_INHERIT_ACE, CONTAINER_INHERIT_ACE, NO_PROPAGATE_INHERIT_ACE | No effect on the walk. These flags control inheritance to child objects; the walk on this object ignores them. |
SUCCESSFUL_ACCESS_ACE_FLAG, FAILED_ACCESS_ACE_FLAG | These are audit/alarm flags and have no effect on access-control ACEs. |
The one to remember is INHERIT_ONLY_ACE. An ACE with that flag set is for inheritance purposes only and does not control access to the object it is on. This is sometimes the source of a confusing "but my ACE is right there in the DACL" — yes, it is, but it is inherit-only, so the walk skips it.
MAXIMUM_ALLOWED: the "what could I get" mode #
Normally the access check stops as soon as every bit in the requested mask is decided. If the caller asked for FILE_READ_DATA | FILE_WRITE_DATA and both bits are decided after three ACEs in a DACL of twenty, the walk stops — there is nothing more to learn.
When the caller sets MAXIMUM_ALLOWED (bit 25) in the requested mask, the access check changes mode. Instead of stopping when the requested bits are decided, it walks the entire DACL and accumulates every bit that any ACE would grant the caller. The returned granted mask is the union of all rights the caller could have gotten.
This is what tools use to display "you have read, write, and delete access to this file" without having to ask for each right separately. It is also what services use when they want to open an object with whatever rights they can get, deferring the actual privilege check to later operations.
Two specific rules about MAXIMUM_ALLOWED:
- The flag itself is stripped from the requested mask before the walk runs. The actual
decided/grantedarithmetic is done on the real bits. - The granted mask is returned through the same channel as a normal access check; the caller learns what they got back.
MAXIMUM_ALLOWED does not affect first-writer-wins. A deny ACE that decides a bit still decides it. The full-walk mode just means the walk does not exit early.
What the DACL walk does not decide #
A handful of access decisions happen outside the DACL walk:
- Owner implicit rights are granted before the walk starts, based on the SD's owner field. Covered in Ownership and implicit rights.
- Privilege-granted rights (SeBackup, SeRestore, SeSecurity, SeTakeOwnership, SeRelabel) are decided before or after the walk, depending on the privilege. The DACL has no say.
- MIC (mandatory integrity) and PIP (process integrity protection) checks happen before the DACL walk for non-dominant callers, and can pre-decide write or other bits as denied.
- Restricted token and confinement intersections happen after the DACL walk and can narrow the result.
- CAAP (central access policies) evaluate alongside the DACL and can further restrict.
- SACL audit happens after the access decision, based on whatever the DACL walk + the other layers produced.
In other words, the DACL walk is one layer in a longer pipeline. It is the layer most people mean when they talk about "permissions", but it is not the whole story. The full pipeline lives in Access decisions.
Practical tips #
A handful of patterns that come up repeatedly:
- When a deny ACE seems to have no effect, check whether it appears after an allow ACE that already decided the bit. The fix is to move it earlier (or move the allow later) — i.e. canonicalise.
- When an inherited ACE seems to override an explicit one, check canonical order. Inherited ACEs should sit after explicit ones; if you find an inherited deny ahead of an explicit allow, the DACL is out of order.
- When testing access, query the full SD and walk it manually if the answer is surprising. Almost every "why doesn't this work" reduces to either a misordered DACL, a misplaced INHERIT_ONLY_ACE, or a missing flag.
- When writing a DACL programmatically, build it in canonical order from the start. Adding ACEs at the end and "sorting later" is fragile.
Where to go next #
For the implicit rights the owner gets before the walk starts — and how to suppress them — read Ownership and implicit rights.
For the full pipeline the walk sits inside — MIC, PIP, privileges, narrowing layers — read Access decisions.
To test what a DACL would grant from a shell, read The sd command.
Ownership and implicit rights
Peios / Peios Security Fundamentals / Security Descriptors
Every security descriptor names an owner — a single SID identifying the principal responsible for the object's access policy. The owner has authority over the object's DACL: they can change it, including granting or revoking access for themselves and others.
That authority is implemented by two implicit rights granted outside the DACL walk: READ_CONTROL (read the SD) and WRITE_DAC (modify the DACL). The owner gets these regardless of what the DACL says. An owner cannot accidentally write a DACL that locks themselves out of their own object — the implicit rights are the floor.
This is the "you cannot lock yourself out" guarantee. It is also why every SD must have an owner: an object with no owner has no fallback authority, and the access check rejects an SD without one.
The implicit rights #
When the access check starts evaluating a DACL, it first checks whether the caller's token represents the SD's owner. If it does, the caller is granted READ_CONTROL and WRITE_DAC before the DACL walk begins. Both bits go into the granted mask and into decided, so no later ACE — allow or deny — can change them.
"Represents the owner" means the caller's user_sid matches the SD's owner SID or the caller's token has the owner SID as a group with the SE_GROUP_OWNER flag set. The flag is on the group, not on the token globally; only groups specifically marked as eligible to act as owner count. This lets a token represent multiple potential owners (the user themselves, plus the administrative group they belong to that owns shared objects).
The implicit grant is just READ_CONTROL and WRITE_DAC. Nothing else. The owner of a file does not automatically get FILE_READ_DATA or FILE_WRITE_DATA — those rights come from the DACL like any other. An owner who has not written themselves into their own DACL can read and modify the policy but cannot read or write the data without first adding an allow ACE.
This split is what makes the model work. The owner controls policy; the DACL controls data access. An owner can lose all data-access rights to their own object (perhaps because every ACE was a deny against them) and still recover, because WRITE_DAC is implicit and lets them rewrite the DACL.
OWNER RIGHTS: suppressing the implicit grant #
Sometimes you want an object where the owner is not allowed to be a backdoor. A sensitive log file owned by a service should not let the service rewrite its own audit policy. A configuration object owned by a user should not let that user override administrator-set restrictions.
The mechanism is the well-known SID S-1-3-4 — OWNER RIGHTS. When any ACE in the DACL names OWNER RIGHTS, the kernel suppresses the implicit grant of READ_CONTROL | WRITE_DAC. The owner gets whatever the ACE says they get, no more.
The rule is more precise than that. Suppression happens when:
- An ACE in the DACL names
S-1-3-4as its SID, and - The ACE has
INHERIT_ONLY_ACEclear (it is a real, evaluated ACE, not an inherit-only placeholder).
If any such ACE is present, the owner's implicit rights are suppressed regardless of what the ACE grants. An OWNER RIGHTS allow ACE granting only FILE_READ_DATA means the owner can read but not write the DACL — they have lost WRITE_DAC entirely.
During the DACL walk, the OWNER RIGHTS SID matches the caller if they are the owner. So an ACCESS_ALLOWED ACE on S-1-3-4 grants those rights to whoever happens to own the object. The ACE composes with the rest of the DACL like any other ACE; first-writer-wins still applies.
The two pieces — suppress implicit, then evaluate normally — let you write DACLs that say:
- "The owner gets exactly read and read-attributes, nothing else, including not WRITE_DAC."
- "The owner has the same rights as any other authenticated user; no special privileges."
- "The owner has no access at all; only specific named principals can touch this object."
Changing ownership #
Ownership is itself a property the access check protects. To change an SD's owner, the caller must hold WRITE_OWNER on the object. By default, that right is in the standard rights section of the access mask (bit 19, value 0x00080000), and a DACL can grant it like any other right.
Once a caller has WRITE_OWNER, they cannot just name any SID as the new owner. The kernel applies one of two rules, depending on the caller's privileges.
Without SeRestorePrivilege, the new owner SID must be either:
- The caller's own
user_sid, or - A SID present in the caller's
groupslist with theSE_GROUP_OWNERflag set.
This restriction is what stops a user with WRITE_OWNER from "transferring" an object to someone else and washing their hands of it. You can take ownership yourself (assuming you have WRITE_OWNER or SeTakeOwnershipPrivilege), but you cannot hand the object to an arbitrary third party.
With SeRestorePrivilege, the restriction is bypassed. The new owner can be any well-formed SID. This is what backup and restore tools use to restore an object whose original owner is not present on the current system.
The kernel's kacs_set_sd enforces both rules at the time of the change — there is no "set owner to anyone, validate later" path.
SeTakeOwnership: an unconditional grant #
SeTakeOwnershipPrivilege is a privilege that grants WRITE_OWNER on any object, regardless of the DACL. A caller holding the privilege can take ownership of anything they can reach — a file whose DACL grants them nothing, a registry key with no allow ACE for them at all.
The privilege does not bypass:
- The "new owner must be self or a SE_GROUP_OWNER group" rule. The caller still has to name themselves (or one of their owner-eligible groups) as the new owner. To name a different principal as the new owner, the caller additionally needs
SeRestorePrivilege. - MIC. A lower-integrity caller cannot take ownership of a higher-integrity object even with the privilege.
- PIP. A non-dominant caller cannot take ownership of an object protected by a higher PIP trust label even with the privilege.
SeTakeOwnership is the lever that makes administrative recovery possible. An object whose DACL has accidentally locked everyone out can still be reached by an administrator with the privilege, who can take ownership, then use the implicit WRITE_DAC to rewrite the DACL. Without the privilege, such an object would be permanently inaccessible.
The privilege is granted very narrowly — typically only to BUILTIN\Administrators and the SYSTEM account. Most service accounts and ordinary users do not have it.
The primary group #
The SD's primary group field is a relic. It exists because the SD format reserves a slot for it and because some legacy code paths consult it, but in Peios its role is almost entirely passive.
The one place the primary group matters is inheritance. When the kernel synthesises a new child SD and an inheritable ACE names CREATOR_GROUP (the well-known SID S-1-3-1), the substitution uses the new child's primary group SID. The primary group is set to the creator's token's primary group at the time of creation.
After inheritance has run, the primary group on the child SD is just a stored value. The access check does not consult it. ACEs do not match it. It exists so the CREATOR_GROUP substitution has a target.
You will rarely need to think about the primary group. The few times it matters are:
- Programs that read SDs and expect to display the primary group alongside the owner.
- Legacy POSIX-style applications that map the primary group to a Linux GID for compatibility purposes.
- Inheritable ACEs that use
CREATOR_GROUPto grant access to "the group of whoever created this".
Most of the time, the primary group is set to whatever the creating token had and is then ignored.
Recovery patterns #
A few patterns for when ownership is the lever you need:
- Lost access to your own file. You wrote a DACL that denies you. You are still the owner; implicit
WRITE_DAClets you rewrite the DACL and add back what you need. - Inherited a mess of objects from a departed user. Their tokens are gone, but their SIDs are still in the DACLs. An administrator with
SeTakeOwnershipcan take ownership of each object (subject to the same-self-or-owner-group rule for the new owner) and then rewrite the DACLs. - Restoring backups. A backup tool with
SeRestorePrivilegecan set arbitrary owners during restore, reproducing the original SDs even when the original principals are not present on the current system. - Locking the owner out deliberately. Use an
OWNER RIGHTSACE — explicitly grant the owner what you want them to have (perhaps nothing) and the implicit grant ofREAD_CONTROL | WRITE_DACwill be suppressed. Use with care; this is what makes objects administratively unrecoverable except viaSeTakeOwnership.
Where to go next #
For how the CREATOR_OWNER and CREATOR_GROUP placeholders get substituted when children are created, read Inheritance.
For how SeTakeOwnership and SeRestore fit the wider privilege model, read Privileges.
To view and change an object's owner from a shell, read The sd command.
Inheritance
Peios / Peios Security Fundamentals / Security Descriptors
When a new object is created under a parent — a file in a directory, a registry key under another key — the new object gets its own security descriptor. The kernel computes that SD at creation time by combining inheritable ACEs from the parent's SD with whatever the creator supplied or defaulted. The result is stored on the child as a complete, self-contained SD. From that point on, the access check on the child consults only the child's own SD; the parent is not walked.
This is the eager evaluation model. It has one large consequence: modifying a parent's inheritable ACEs does not propagate to existing children. The children's SDs were computed when they were created and are now their own. Whatever changes you make to the parent affect only objects created after the change. Children that already exist keep the SDs they had.
This page covers the inheritance algorithm — what counts as inheritable, how the merge works, the special placeholders (CREATOR_OWNER, CREATOR_GROUP), and the SE_DACL_PROTECTED and SE_SACL_PROTECTED flags that opt an object out of inheritance entirely.
Eager vs lazy #
Two models are possible:
- Lazy inheritance: a child has no inherited ACEs of its own; access checks on the child walk the parent SD too. Changes to the parent propagate immediately.
- Eager inheritance: a child's SD is fully computed at creation; access checks consult only the child. Changes to the parent affect future children only.
Peios uses eager inheritance, like the access-control model it grew out of. The advantages: access checks are local (you only need one SD's worth of data), no walking up directory trees during evaluation, no concurrency issues with "the parent moved between my permission check and the operation". The disadvantage: when you change a parent's permissions, you need a separate sweep to propagate the change to existing children — a tool's job, not the kernel's.
In practice, eager inheritance means: when an administrator changes a directory's DACL and wants the change to apply to existing files, they need a propagation tool. The kernel does not do this automatically and provides no syscall for "re-inherit all children". A propagation tool walks the tree and rewrites child SDs explicitly.
The inheritance flags #
Four ACE flags control how an ACE propagates to children. They are the same four mentioned on the ACLs and ACEs page:
| Flag | Effect at creation of a child |
|---|---|
OBJECT_INHERIT_ACE (OI, 0x01) | The ACE inherits to children that are non-containers (files, not directories). |
CONTAINER_INHERIT_ACE (CI, 0x02) | The ACE inherits to children that are containers (directories). |
NO_PROPAGATE_INHERIT_ACE (NP, 0x04) | The ACE inherits to direct children but its OI/CI flags are cleared in the inherited copy, so it stops propagating after one level. |
INHERIT_ONLY_ACE (IO, 0x08) | The ACE is not evaluated on the object it sits on. It exists only to be inherited. |
Common combinations:
| Flags | Meaning |
|---|---|
CI | OI | Inherit to all descendants, files and directories, recursively. The most common pattern. |
CI | Inherit to descendant directories only. |
OI | Inherit to descendant files only. |
CI | OI | IO | Apply to descendants but not to this object. |
CI | OI | NP | Apply to immediate children only. Grandchildren do not inherit. |
(none) | This ACE does not inherit. It applies only to this object. |
The INHERIT_ONLY_ACE (IO) flag is what lets you say "this rule is for children, not for the parent itself". An IO ACE is invisible to the access check on its own object; the DACL walk skips it. But it is copied to children at creation time.
The merge algorithm #
When a new child is created, the kernel needs to produce the child's SD from three inputs:
- The parent's SD. Inheritable ACEs from here will be copied (with adjustments) into the child.
- The creator's SD, if one was supplied as a parameter to the create call. This contains explicit ACEs the creator wants on the new object.
- The creator's token. Provides defaults (owner, primary group, default DACL) when the creator did not supply specific values.
The merge proceeds as follows:
- Compute the child's owner. If the creator supplied an owner in the explicit SD, use it (subject to the same-self-or-owner-group rule from Ownership). Otherwise, use the creator's token's default owner.
- Compute the child's primary group. Same rule: explicit SD if present, otherwise the creator's token's default primary group.
- Compute the child's DACL. Start with explicit ACEs from the creator's SD (or, if the creator did not supply a DACL, the creator's token's
default_dacl). Then append inheritable ACEs from the parent's DACL — but only those whose flags say they should propagate to this kind of child (container or non-container). - Compute the child's SACL. Same algorithm as the DACL, applied to SACL ACEs.
Each inherited ACE goes through a small transformation as it is copied:
- The
INHERITED_ACEflag (0x10) is set in the copy. This marks it as having come from inheritance, not from explicit assignment. - If
NO_PROPAGATE_INHERIT_ACEwas set, theOIandCIflags are cleared in the copy. (NP itself is also typically cleared.) - If the ACE's SID is
CREATOR_OWNER(S-1-3-0), the SID is substituted with the new child's owner SID. - If the ACE's SID is
CREATOR_GROUP(S-1-3-1), the SID is substituted with the new child's primary group SID.
The CREATOR substitution is the mechanism that makes inheritable ACEs portable. A directory's DACL containing CI | OI ACCESS_ALLOWED CREATOR_OWNER GENERIC_ALL means "every file or subdirectory created under here grants full access to whoever owns it". The owner of each child is different; the substitution at creation time fills in the right SID for each one.
Sources of an inherited SD, ranked #
Which source wins for each component of the child SD, in order:
| Component | First source | Second source | Third source |
|---|---|---|---|
| Owner | Creator's explicit SD (if owner present) | Creator's token's default owner | (none — must be present) |
| Primary group | Creator's explicit SD (if group present) | Creator's token's default primary group | (none — must be present) |
| DACL | Creator's explicit SD (if DACL present) | Creator's token's default_dacl | (no DACL means NULL DACL) |
| SACL | Creator's explicit SD (if SACL present) | (no fallback) | (no SACL means no audit policy) |
Plus, in every case, inheritable ACEs from the parent are appended to whatever DACL/SACL was chosen as the base. The parent's contributions never replace; they always extend.
This means: a creator who supplies an explicit DACL gets that DACL plus the inheritable ACEs from the parent. They cannot exclude the parent's ACEs except by setting the protected flag (see below).
Protected ACLs #
There is an opt-out: the SE_DACL_PROTECTED and SE_SACL_PROTECTED flags in the SD's control field.
| Flag | Effect |
|---|---|
SE_DACL_PROTECTED (0x1000) | The child's DACL does not accept inheritable ACEs from the parent. Only the explicit ACEs in the creator's SD (or the creator's default_dacl) appear. |
SE_SACL_PROTECTED (0x2000) | Same, for the SACL. |
When a creator sets these flags on the explicit SD they pass to the create call, the resulting child's DACL/SACL is purely what the creator wrote. The parent might have a hundred inheritable ACEs; none of them appear on the child.
Protected ACLs are used when an object should be administered without reference to its container. A service that creates files whose access should be controlled only by the service, not by whoever happens to own the directory it lives in, sets SE_DACL_PROTECTED on each file's explicit SD at creation.
The flags can also be set after creation, via a subsequent kacs_set_sd. Doing so does not retroactively remove previously inherited ACEs — they were copied at creation time and are now the child's own — but it does protect against future re-inheritance if a propagation tool sweeps the tree.
Auto-inherit flags #
Two more SD control flags work alongside the protected flags:
| Flag | Effect |
|---|---|
SE_DACL_AUTO_INHERIT_REQ (0x0100) | Set by a creator that wants the child to participate in auto-inheritance. Tools that propagate inherited ACEs use this to decide whether to update the child. |
SE_DACL_AUTO_INHERITED (0x0400) | Set by the kernel on a child whose inherited ACEs have been computed by the auto-inheritance algorithm. |
The corresponding SACL pair (SE_SACL_AUTO_INHERIT_REQ, SE_SACL_AUTO_INHERITED) works the same way.
These flags exist so tools can distinguish "this child wants automated propagation" from "this child has its own bespoke DACL the user wrote by hand". A propagation tool sweeping a tree typically updates only children with the auto-inherit flags set, leaving hand-crafted DACLs untouched.
What inheritance does not do #
A handful of things often attributed to inheritance but not done by it:
- Inheritance does not happen at access-check time. Once a child has its SD, the parent is not consulted on access decisions. Inheritance is purely a creation-time activity.
- Inheritance does not retroactively update children. A change to a parent's inheritable ACEs is invisible to children created before the change. Propagation requires an explicit sweep.
- Inheritance does not chain across moves. Moving a file from directory A to directory B does not re-run inheritance against B. The file's SD remains whatever it was. (Copying is a different operation; it creates a new object, which does go through inheritance.)
- Inheritance does not preserve ACE order. Inheritable ACEs from the parent are appended to the explicit ACEs from the creator. If you want canonical order, the creator needs to either start from the parent's order and intersperse, or run a canonicalisation pass afterwards.
Inheritance and creator-supplied SDs #
A subtle interaction worth knowing: when a creator passes an explicit SD to the create call, the explicit ACEs go before the inherited ones. This means the creator's ACEs sit higher in the DACL than the parent's, and first-writer-wins favours them.
If the parent says "deny X" and the creator says "allow X", the creator's allow wins because it sits first. If the canonical order says the parent's inherited deny should win (because explicit ACEs from the creator should not override the parent's policy), the creator needs to either not supply a competing ACE or arrange the explicit SD to use the protected-ACL flag.
This is one of the reasons creators usually pass no explicit DACL (relying on the default-DACL + parent-inheritance merge) rather than constructing a custom SD. The latter is correct in cases where the creator knows exactly what the child's policy should be; the former is correct in cases where the creator just wants the natural policy of its location.
Where to go next #
For ACEs whose effect is gated by an expression rather than fixed at write time, read Conditional ACEs.
To sweep a tree and propagate inheritable ACEs from a shell, read The sd command.
Conditional ACEs
Peios / Peios Security Fundamentals / Security Descriptors
A conditional ACE is an ACE whose effect depends on a boolean expression. Where an ordinary allow ACE says "grant these rights to this principal", a conditional allow ACE says "grant these rights to this principal if this expression is true". The expression can reference attributes of the calling token (the user's department, the device's compliance state), attributes of the object (its classification, its sensitivity), and runtime context supplied by the caller.
Conditional ACEs are how Peios expresses attribute-based access control — ABAC — as opposed to identity-based access control where every rule names a specific principal or group. The conditional model is strictly an extension: every conditional ACE is also keyed to a SID, so it composes naturally with the rest of the DACL. The expression is an extra gate on top.
This page covers the model, the expression language at a conceptual level, and the three-valued logic that makes the model fail safely when an expression cannot be evaluated.
The model #
A conditional ACE is one of the callback ACE types from ACLs and ACEs:
| Type | Behaviour |
|---|---|
ACCESS_ALLOWED_CALLBACK | Allow ACE, gated by expression. |
ACCESS_DENIED_CALLBACK | Deny ACE, gated by expression. |
ACCESS_ALLOWED_CALLBACK_OBJECT | Allow ACE, gated by expression, scoped by GUID. |
ACCESS_DENIED_CALLBACK_OBJECT | Deny ACE, gated by expression, scoped by GUID. |
SYSTEM_AUDIT_CALLBACK | Audit ACE, gated by expression. |
SYSTEM_AUDIT_CALLBACK_OBJECT | Audit ACE, gated by expression, scoped by GUID. |
SYSTEM_ALARM_CALLBACK | Alarm ACE, gated by expression. |
SYSTEM_ALARM_CALLBACK_OBJECT | Alarm ACE, gated by expression, scoped by GUID. |
The body of a callback ACE is the body of the corresponding non-callback ACE — Mask, optional GUIDs, SID — plus a trailing ApplicationData block holding the conditional expression in a binary bytecode format. The bytecode begins with the four-byte magic 0x61 0x72 0x74 0x78 (the ASCII string artx); a callback ACE whose ApplicationData does not start with that magic is treated as having an UNKNOWN expression and falls into the UNKNOWN-handling rules below.
During the DACL walk, the access check evaluates the expression at the moment the ACE is examined. The result is TRUE, FALSE, or UNKNOWN. What happens next depends on the ACE type and the result:
| ACE class | TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| Allow callback | ACE applies (grants its rights) | ACE is skipped | ACE is skipped |
| Deny callback | ACE applies (denies its rights) | ACE is skipped | ACE applies (denies) |
| Audit callback | Emit event | Skip | Emit event |
| Alarm callback | Configure continuous-audit mask | Skip | Configure mask |
The asymmetry between allows and denies is deliberate. An UNKNOWN result on an allow is skipped (the allow does not get to grant access on incomplete information). The same UNKNOWN on a deny does apply (the deny errs on the side of denying). And UNKNOWN on audit fires the event (the audit errs on the side of recording).
The principle is that UNKNOWN never accidentally grants. It can only fail safely closed, or fail safely open for auditing.
The four attribute namespaces #
Conditional expressions reference attributes through four namespaces, each backed by a different source:
| Namespace | Source | Set by |
|---|---|---|
@User.<name> | The caller's token's user_claims field. | authd at token creation, from the user's directory object. |
@Device.<name> | The caller's token's device_claims field. | authd at token creation, from the machine's directory object. |
@Resource.<name> | A resource attribute ACE in the object's SACL. | The object's administrator, via kacs_set_sd. |
@Local.<name> | A per-access-check parameter supplied by the caller. | Whatever code is making the access check. |
So an expression like @User.Department == "Engineering" refers to a claim on the caller's token. @Resource.Classification == "Public" refers to an attribute on the object being accessed. @Local.Time > 9 refers to context the calling code passed in. The four namespaces let an ACE reference the caller, the object, and the runtime situation in one expression.
Two of the namespaces are token-side (caller properties), one is object-side (resource properties), and one is per-call context. They cover the three "where can an attribute come from?" possibilities cleanly.
User and device claims are covered on Claims on a token. Resource attributes are covered on Resource attributes. Local claims are passed by the caller; the API surface lives in the Kernel ABI reference.
The expression language #
The expression bytecode is postfix (reverse Polish notation), evaluated by a stack machine. Literal tokens push values onto the stack; operator tokens pop operands, do their work, and push a result. The final stack must contain exactly one tri-state value, which is the expression's result.
On the wire an expression is bytecode; the textual form, which sd --if accepts, looks like:
@User.Department == "Engineering"
@User.Department == "Engineering" && @Resource.Classification != "Secret"
Member_of({SID-of-AdminGroup})
@Device.Compliance == "Compliant" && @Local.Time >= 9 && @Local.Time <= 17
Exists(@User.ProjectAccess) && @User.ProjectAccess Any_of {"alpha", "beta"}
The operators fall into four families:
| Family | Operators |
|---|---|
| Relational | ==, !=, <, <=, >, >= |
| Set / membership | Contains, Exists, Any_of, Member_of, Device_Member_of, Member_of_Any, Device_Member_of_Any, plus Not_* variants |
| Logical | &&, ` |
| Attribute references | @User., @Device., @Resource., @Local. |
The membership operators reference SID sets — Member_of({S-1-5-21-...}) evaluates TRUE if the caller's token has that SID in its groups. Any_of evaluates TRUE if any value in a multi-valued attribute appears in a literal set. Contains and Exists test for presence rather than equality.
The full operator catalog with bytecode values lives in Wire formats reference.
Value types and comparisons #
Expressions operate on six value types: INT64, UINT64, STRING, SID, BOOLEAN, OCTET. Comparisons follow rules you would expect:
- Numeric comparisons work across
INT64andUINT64with sign-aware promotion: a negativeINT64is always less than anyUINT64. - String comparisons are case-insensitive by default. A claim or attribute can opt into case sensitivity via the
CLAIM_SECURITY_ATTRIBUTE_VALUE_CASE_SENSITIVEflag (see Claims on a token). - SID comparisons are byte-exact, same as everywhere else.
- Octet comparisons are byte-exact.
Boolean coercion of other types happens when an expression treats a non-boolean as a boolean (rarely, but it can happen): a nonzero integer is TRUE, zero is FALSE, NULL is UNKNOWN, SIDs/octets/composites are UNKNOWN. Compositions of types that do not coerce cleanly produce UNKNOWN.
The "missing attribute" case #
The most common UNKNOWN producer is a reference to an attribute the token or object does not carry. @User.ClearanceLevel on a token that has no ClearanceLevel claim evaluates to UNKNOWN — not to NULL, not to FALSE, not to an empty string. UNKNOWN.
This is why the three-valued logic exists. Schemas evolve; not every user object has every defined attribute; the access check needs to behave well when an attribute is missing. The rule "UNKNOWN skips an allow, applies a deny, fires an audit" means a missing attribute can never accidentally upgrade access — at worst, the gated allow does nothing.
Three-valued logic #
The truth tables for &&, ||, and ! over {TRUE, FALSE, UNKNOWN}:
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 (!) #
| Input | Output |
|---|---|
| TRUE | FALSE |
| FALSE | TRUE |
| UNKNOWN | UNKNOWN |
The pattern: a definite value (TRUE or FALSE) combined with UNKNOWN can sometimes determine the result (FALSE && UNKNOWN is FALSE; TRUE || UNKNOWN is TRUE) and sometimes cannot (TRUE && UNKNOWN is UNKNOWN; FALSE || UNKNOWN is UNKNOWN). NOT preserves UNKNOWN.
This means you cannot prove an UNKNOWN attribute is false by negating it. ! Exists(@User.ClearanceLevel) evaluates to UNKNOWN if the attribute is missing, because Exists returns UNKNOWN in that case and the NOT preserves it. The right way to check "the user does not have this attribute" is the Not_Exists operator, which returns TRUE for a missing attribute and FALSE for a present one.
A worked example #
Consider an SD on a sensitive file with this DACL:
ACCESS_DENIED_CALLBACK Everyone (when @Resource.Classification == "TopSecret" && Not_Member_of({Cleared-SID}))— deny everyone when the file is TopSecret and they are not in the Cleared groupACCESS_ALLOWED Authenticated_Users GENERIC_READ— allow read to authenticated usersACCESS_ALLOWED Authenticated_Users GENERIC_WRITE— allow write to authenticated users
The resource attribute is set on the file's SACL: Classification = "TopSecret".
User Alice, who is not in the Cleared group, tries to read.
- ACE 1: matches Alice (Everyone matches all tokens). Expression:
Classification == "TopSecret"is TRUE (the file's resource attribute matches),Not_Member_of({Cleared-SID})is TRUE (Alice is not in the group). Both TRUE, so AND is TRUE. The deny applies.FILE_READ_DATAand friends are decided and not granted. - ACE 2: also matches Alice, but
FILE_READ_DATAetc. are already decided. The allow has no effect. - ACE 3: same.
Alice gets nothing. The conditional deny did its job.
Now user Bob, who is in the Cleared group:
- ACE 1: matches Bob. Expression:
Classification == "TopSecret"is TRUE,Not_Member_of({Cleared-SID})is FALSE. TRUE AND FALSE is FALSE. The deny does not apply. - ACE 2: matches Bob.
FILE_READ_DATAnot yet decided. Granted. - ACE 3: matches Bob.
FILE_WRITE_DATAnot yet decided. Granted.
Bob gets read and write. The conditional deny knew not to apply to him.
Now suppose the file's classification attribute is missing (administrative mistake):
- ACE 1: expression
Classification == "TopSecret"is UNKNOWN (the attribute does not exist). AND of UNKNOWN with anything: depends on the other operand. Even ifNot_Member_ofis TRUE, UNKNOWN AND TRUE is UNKNOWN. The deny ACE has UNKNOWN, which applies (denies err on the side of denying).FILE_READ_DATAis decided as denied. - ACE 2, 3: same fate; already decided.
Both Alice and Bob get nothing. The missing attribute caused the deny to fire, not the allow to grant. Fail safe.
When conditional ACEs are the right tool #
Conditional ACEs are the right tool when:
- The set of principals who should have access varies based on attributes that change more often than the SD does. A "members of the Engineering department who joined after 2020" rule is one expression; encoding it as a group membership would require maintaining the group as people join.
- Access depends on something that is not the principal's identity at all — the resource's classification, the time of day, the integrity of the request.
- The same DACL needs to express many cases without duplication. One conditional ACE covers what would otherwise be N parallel ACEs.
They are the wrong tool when:
- The condition reduces to "is this user in this group?". Use a group SID and a plain ACE; it is simpler and faster.
- The condition is a static identity check at a fixed point in time. Use a SID and a plain ACE.
- The expression depends on state that changes faster than you can update the SD. Conditional ACEs evaluate at access-check time using the data on the token and object now; if the relevant data is stale, the result is stale.
Most DACLs in practice use no conditional ACEs at all. They appear in the SDs of objects whose access policy is genuinely attribute-driven — central access policies, data-classification-driven sharing, time-bound access. For everything else, plain ACEs are clearer.
Where to go next #
For the object-side attributes that @Resource.* references resolve against, read Resource attributes.
For the token-side attributes behind @User.* and @Device.*, read Claims on a token.
Resource attributes
Peios / Peios Security Fundamentals / Security Descriptors
A resource attribute is a typed key-value attribute attached to an object — much like a claim on a token, but applied to the resource rather than the principal. A file might have Classification = "Internal". A registry key might have Department = "Finance". A process or token might have a Compliance = TRUE attribute. These attributes do not grant or deny access by themselves; they are inputs that conditional ACEs can reference as @Resource.<name>.
Resource attributes are stored in the SACL using the SYSTEM_RESOURCE_ATTRIBUTE_ACE type. They share the binary format used by token claims: same value types, same flags, same case-sensitivity rules. The key difference is location — claims live on a token, resource attributes live on an object's SD.
What a resource attribute looks like #
Each resource attribute carries:
- A name — a string, conventionally
Namespace.Attribute(Classification,Department,Project.Code). - A value — typed. One of
INT64,UINT64,STRING,SID,BOOLEAN,OCTET. Single or multi-valued, but homogeneous (no mixed-type arrays). - A set of flags controlling how the attribute participates in access checks.
The flags are the same set used for claims:
| Flag | Effect |
|---|---|
DISABLED (0x0010) | The attribute is invisible to all conditional expressions. Effectively does not exist. |
USE_FOR_DENY_ONLY (0x0004) | The attribute is invisible to conditions on allow ACEs, but visible to conditions on deny ACEs. Lets an administrator quickly demote an attribute. |
MANDATORY (0x0020) | The attribute cannot be removed or modified without SeTcbPrivilege. Used for system-maintained attributes. |
CASE_SENSITIVE (0x0002) | String and octet comparisons against this attribute are case-sensitive. Default is case-insensitive. |
These are the same flags described on Claims on a token, applied here to attributes on the resource side.
Storage in the SACL #
A resource attribute lives in a SYSTEM_RESOURCE_ATTRIBUTE_ACE (type 0x12). The ACE body uses a single-SID layout where the SID is always Everyone (S-1-1-0); the SID slot is structural, not semantic — resource attribute ACEs do not match against tokens like access-control ACEs do. The real payload is in the ApplicationData portion, which carries one claim entry in the same format as a token claim.
| ACE field | Resource attribute value |
|---|---|
| AceType | SYSTEM_RESOURCE_ATTRIBUTE_ACE (0x12) |
| Mask | Reserved. Not used in access decisions. |
| SID | Always S-1-1-0 (Everyone). |
| ApplicationData | One claim entry: name, type, flags, value(s). |
A single SACL can hold many resource attribute ACEs — one per attribute. The full ABI layout of the claim entry is in the Wire formats reference.
Two structural notes:
- The first non-inherit-only ACE for each name wins. If two
SYSTEM_RESOURCE_ATTRIBUTE_ACEACEs in the SACL define the same attribute name, only the first one is used. Duplicates after the first are ignored. - Resource attributes are located by type-scan, not position. Unlike DACL ACEs, the order of resource attribute ACEs within a SACL is not load-bearing beyond the first-wins tiebreak. The access check finds them by scanning for the type, not by walking.
How resource attributes are used #
When the access check evaluates a conditional ACE that references @Resource.<name>, the lookup runs:
- Scan the object's SACL for a
SYSTEM_RESOURCE_ATTRIBUTE_ACEwhose attribute name matches<name>. - If found, read the value(s) and use them in the expression.
- If not found, the reference produces UNKNOWN. The expression continues with that value, subject to three-valued logic.
The "not found" case is what makes the design tolerant of schema evolution. A conditional ACE that references @Resource.Classification works whether or not the specific object has a Classification attribute — if it has one, the expression uses it; if not, the expression evaluates UNKNOWN at that subexpression, and the three-valued logic handles the rest.
Resource attributes do not need to be referenced to exist. An object can carry attributes that no current ACE references — perhaps the schema is being prepared for future use, or the attributes are read by separate tooling that scans the SACL directly. The access check ignores resource attribute ACEs that no conditional expression names; they are inert from its perspective.
Setting and modifying resource attributes #
Resource attributes are part of the SACL. Modifying them goes through kacs_set_sd with SACL_SECURITY_INFORMATION in the security-information mask, which requires ACCESS_SYSTEM_SECURITY on the object. In practice this means a caller holding SeSecurityPrivilege — the same privilege that gates other SACL modifications.
One additional rule: a resource attribute marked MANDATORY cannot be removed or modified except by a caller holding SeTcbPrivilege. This is the lever for system-maintained attributes — values the kernel or a TCB component sets that ordinary administrators should not be able to overwrite.
Replacing the SACL with a new one that omits a MANDATORY attribute is also rejected; the kernel checks the diff, not just the new value.
Namespacing #
Resource attribute names are arbitrary strings. By convention, attributes use a namespace prefix:
Project.Code— the project this resource belongs to.Department.Name— the owning department.Compliance.Status— a compliance classification.
The convention is just a convention. The access check compares names as strings; Project.Code and ProjectCode are different attributes. Establishing a stable naming scheme is the administrator's job.
In the conditional expression, the reference is @Resource.<name> regardless of whether the name itself contains dots:
@Resource.Project.Codereferences an attribute named exactlyProject.Code.@Resource.Departmentreferences an attribute named exactlyDepartment.
The expression parser treats the dot as part of the attribute name, not as a structural separator.
Resource attributes vs claims vs local context #
Resource attributes sit alongside two other attribute sources that conditional expressions can reference. The three together give the conditional model its expressiveness:
| Source | Lives on | Referenced as | Set by |
|---|---|---|---|
| User claims | Token | @User.<name> | authd, from the user directory object |
| Device claims | Token | @Device.<name> | authd, from the machine directory object |
| Resource attributes | Object SACL | @Resource.<name> | Object administrator, via kacs_set_sd |
| Local claims | Per AccessCheck call | @Local.<name> | The caller, passed as a parameter |
The four namespaces let a single conditional expression weave together "who is the caller", "what machine are they on", "what is the object's property", and "what is the runtime context". The most common pattern uses two of them — caller and resource — for classification-based access. The remaining two appear when machine identity matters or when the calling code wants to supply runtime context.
What resource attributes are not #
A few things resource attributes look like at a glance but are not:
- Resource attributes are not part of the data. They are policy metadata, stored in the SACL, separate from the object's content. Reading the object's data does not give you access to the attribute, and vice versa.
- Resource attributes are not ACEs themselves. They live in
SYSTEM_RESOURCE_ATTRIBUTE_ACEentries, but the ACE form is structural — the attribute does not grant or deny anything. The access check skips it during the DACL walk and during the SACL audit walk. - Resource attributes do not propagate via inheritance. They are not inherited from a parent object to a child. A child inherits the parent's inheritable DACL/SACL access-control ACEs; resource attribute ACEs do not have inheritance semantics. If you want the same attribute on every file in a directory, you set it on each one (or use tooling that walks the tree).
- Resource attributes are not searchable through the access check. The access check uses attributes for evaluating expressions, not for filtering objects. There is no "list all objects where
@Resource.Classification == 'Public'" API. That would be a job for a separate indexing layer.
Where to go next #
For the SACL these attributes live in — and everything else stored alongside them — read The SACL.
For the principal-side counterpart to resource attributes, read Claims on a token.
The SACL
Peios / Peios Security Fundamentals / Security Descriptors
The SACL — System Access Control List — is the half of a security descriptor that holds system-level policy. Where the DACL decides "who can do what to this object", the SACL covers everything else the kernel needs to know about the object for security purposes: which access attempts to audit, what integrity level the object has, what PIP trust label it carries, which central access policies apply to it, and what attributes it exposes for conditional evaluation.
The SACL is not part of the access-grant decision in the way the DACL is. Most of its entries do not grant or deny anything; they configure policy that runs alongside or after the DACL walk. The two access-related entries — SYSTEM_AUDIT and SYSTEM_ALARM — fire events but do not gate access.
This page covers each type of SACL entry, how the access check consumes it, and the special status of SACL modification.
What the SACL holds #
A SACL is an ACL — same on-wire structure as a DACL, the same AceCount/AclSize header and the same ACE format. But the ACE types it holds are different. While a DACL is dominated by access-control types (ACCESS_ALLOWED, ACCESS_DENIED, callbacks), a SACL holds the system-policy types:
| ACE type | Purpose |
|---|---|
SYSTEM_AUDIT | Fire an audit event when access matches. One-shot at handle creation. |
SYSTEM_AUDIT_OBJECT | Same, GUID-scoped. |
SYSTEM_AUDIT_CALLBACK | Same, with a conditional expression gating when the audit fires. |
SYSTEM_AUDIT_CALLBACK_OBJECT | Same, GUID-scoped and conditional. |
SYSTEM_ALARM | Configure per-operation continuous audit on an open handle. |
SYSTEM_ALARM_OBJECT | Same, GUID-scoped. |
SYSTEM_ALARM_CALLBACK | Same, conditional. |
SYSTEM_ALARM_CALLBACK_OBJECT | Same, GUID-scoped and conditional. |
SYSTEM_MANDATORY_LABEL | The object's mandatory integrity label and policy bits (MIC). |
SYSTEM_RESOURCE_ATTRIBUTE | A typed key-value attribute on the object (covered separately under Resource attributes). |
SYSTEM_SCOPED_POLICY_ID | A reference to a central access policy (CAAP). |
SYSTEM_PROCESS_TRUST_LABEL | The object's PIP trust label and the explicit allowed mask for non-dominant callers. |
The numeric type values are in the canonical catalog, ACE types and flags.
A SACL can mix these in any combination. Most SACLs in practice are small — a few audit ACEs, perhaps a mandatory label, sometimes a resource attribute or two. SACLs with every type populated are rare and exist only on the most highly-protected objects.
Audit ACEs: one event per access attempt #
A SYSTEM_AUDIT ACE specifies: when this principal attempts these rights on the object, emit an audit event if the access succeeded (SUCCESSFUL_ACCESS_ACE_FLAG set) or if it failed (FAILED_ACCESS_ACE_FLAG set) or both.
The event fires at the moment the access check completes. One event per matching audit ACE per access attempt. A handle opened with FILE_READ_DATA | FILE_WRITE_DATA against an SD with an audit ACE matching both rights produces one event covering both. A separate handle later, with separate access checks, produces separate events.
Audit ACEs match like other ACEs: the SID in the ACE is compared against the caller's identity. Audit ACE matching uses deny polarity — it considers the broadest identity view, including deny-only groups, so that any identity the caller has that should produce an audit event will produce one even if it would not match an allow ACE.
The callback variants (SYSTEM_AUDIT_CALLBACK, etc.) add a conditional expression. If the expression evaluates TRUE or UNKNOWN, the event fires; if FALSE, it does not. This is why conditional audit fails open — recording something is the safe choice when uncertain.
The full audit model is covered in Auditing.
Alarm ACEs: an event per operation #
SYSTEM_ALARM configures continuous auditing, not one-shot. The ACE's mask is recorded on the open handle as a continuous audit mask. From then on, every operation performed on the handle has its required-access mask compared against the continuous mask; if any bit overlaps, an event fires.
The difference between AUDIT and ALARM is in granularity. AUDIT records "this principal opened this object with these rights". ALARM records "this principal opened this object with these rights, and then performed this specific operation, then this one, then this one". The trade-off is event volume — alarm ACEs on a hot path can produce many events; audit ACEs produce one per handle.
ALARM is the right tool for objects where post-open behaviour is what matters: an audited credential store, a sensitive log file, a security-critical configuration. For most objects, AUDIT is enough.
SYSTEM_MANDATORY_LABEL: the integrity floor #
A SYSTEM_MANDATORY_LABEL ACE sets the object's mandatory integrity level. The ACE has two parts:
- A SID from the
S-1-16-*integrity namespace, whose single sub-authority is the numeric level (S-1-16-8192for Medium,S-1-16-12288for High, and so on — any single-sub-authorityS-1-16value is a valid level, compared numerically). - A mask containing the MIC policy bits:
SYSTEM_MANDATORY_LABEL_NO_READ_UP(0x01),SYSTEM_MANDATORY_LABEL_NO_WRITE_UP(0x02),SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP(0x04).
When the access check evaluates the SD, it scans for a SYSTEM_MANDATORY_LABEL ACE. The first non-inherit-only one is the object's effective label. If none is present, the object's effective integrity is Medium with NO_WRITE_UP (the default).
A caller whose effective token's integrity level is below the object's label cannot perform the right categories blocked by the policy bits. The check runs before the DACL walk. A non-dominant caller's deny is decided before any allow ACE in the DACL has a chance to grant the same right.
The label is independent of identity. Two users at different integrity levels with identical SIDs see the same DACL but different effective access, because the label-vs-token-integrity comparison happens first.
MIC is covered in detail in Access decisions.
SYSTEM_PROCESS_TRUST_LABEL: the PIP gate #
A SYSTEM_PROCESS_TRUST_LABEL ACE opts the object in to process integrity protection (PIP). The ACE has:
- A SID of the form
S-1-19-T-LwhereTis the PIP type andLis the trust level. - A mask specifying the rights that non-dominant callers are explicitly allowed.
PIP enforcement is two-step. First, the access check determines whether the caller's PSB dominates the object's trust label — that is, the caller's pip_type is at least the ACE's type AND the caller's pip_trust is at least the ACE's trust level. If the caller dominates, the trust label imposes no restrictions. If the caller does not dominate, only the rights in the ACE's mask are permitted, and privilege-granted rights are revoked (which is what makes PIP different from MIC).
Objects without a PIP trust label ACE are unprotected — PIP is opt-in. The access check does not impose PIP rules unless the SACL specifically asks for them.
PIP is covered in Process integrity protection.
SYSTEM_SCOPED_POLICY_ID: central access policies #
A SYSTEM_SCOPED_POLICY_ID ACE references a centrally-defined access policy by SID. The policy itself is distributed by authd (from loregd on standalone machines, from the machine's directory source on domain-joined ones) and held in a kernel cache. When the access check sees this ACE, it looks up the referenced policy and evaluates its rules in addition to the object's own DACL — intersecting the result, never widening.
Multiple SYSTEM_SCOPED_POLICY_ID ACEs in one SACL apply multiple policies. The result is the intersection of all of them with the object's DACL.
The mechanism — applies-to expressions, staged vs effective rules, the recovery policy for when a referenced policy is missing — lives in Central access policies. For this page, what matters is that the entry exists, sits in the SACL, and is looked up by SID at access time.
SYSTEM_RESOURCE_ATTRIBUTE: typed properties on the object #
SYSTEM_RESOURCE_ATTRIBUTE carries a typed key-value attribute on the object — referenceable as @Resource.<name> in conditional expressions on this or any other SD. The structural role is described above; the full story is in Resource attributes.
How the SACL is consumed #
The access check consults the SACL twice, at different points:
- Before the DACL walk, the check scans the SACL for the MIC, PIP, and scoped-policy ACEs. MIC and PIP pre-decide certain bits as denied for non-dominant callers; scoped policies are noted for later evaluation. Resource attribute ACEs are scanned and indexed so that conditional expressions in the DACL can reference them.
- After the DACL walk, the check scans the SACL again for
SYSTEM_AUDITandSYSTEM_ALARMACEs and decides which events to fire. The MIC, PIP, and scoped-policy ACEs are not consulted again here — their job is done.
This two-pass shape is why the SACL is "structural" rather than "evaluated": its job is to set policy for the DACL walk and to record audit about the DACL walk's outcome, not to participate in the walk itself.
Modifying the SACL #
The DACL is gated by WRITE_DAC, which the owner has implicitly. The SACL is gated by ACCESS_SYSTEM_SECURITY, which the owner does not have implicitly. To modify a SACL, a caller needs:
- Either
SeSecurityPrivilege(which grantsACCESS_SYSTEM_SECURITYon any object), or SeRestorePrivilege(which grants it for specifickacs_set_sduse cases — backup/restore tooling).
WRITE_DAC does not grant ACCESS_SYSTEM_SECURITY. An owner who can rewrite a DACL freely cannot touch the SACL. This is deliberate: the SACL holds audit policy and system-level constraints that the object's owner should not be able to unilaterally remove. An audited object's audit policy is the administrator's, not the user's.
Modifying a SACL that contains a MANDATORY-flagged resource attribute is further restricted: removing or changing a mandatory attribute requires SeTcbPrivilege (covered under Resource attributes).
The LABEL_SECURITY_INFORMATION shortcut #
The kacs_set_sd syscall takes a security_information bitmask saying which SD components to update. Two of the bits are relevant here:
| Flag | Effect |
|---|---|
SACL_SECURITY_INFORMATION (0x08) | Update the SACL (all of it). Requires ACCESS_SYSTEM_SECURITY. |
LABEL_SECURITY_INFORMATION (0x10) | Update only the mandatory integrity label ACE within the SACL. |
LABEL_SECURITY_INFORMATION is the right tool when you want to change an object's integrity level without touching its audit policy. Setting an integrity label downward (to a level at or below the caller's own integrity level) is allowed; setting it above requires SeRelabelPrivilege.
The two flags are mutually exclusive in a single kacs_set_sd call. You either replace the entire SACL or update only the label, not both at once.
What the SACL is not #
A few clarifications:
- The SACL is not for "extra security". It is the kernel's place for system-level policy. Putting more entries in the SACL does not make an object more secure; it just records more policy.
- The SACL is not where the DACL goes if you forget the DACL. The two lists are structurally distinct in the SD format; an SD with a missing DACL but a populated SACL has a NULL DACL (grant all access) regardless of what the SACL says.
- The SACL is not visible to ordinary users. Reading the SACL —
kacs_get_sdwithSACL_SECURITY_INFORMATION— also requiresACCESS_SYSTEM_SECURITY. An owner can read their object's DACL freely but cannot read its SACL without the same privilege required to modify it.
Where to go next #
For the events audit and alarm ACEs produce, and where they go, read Auditing.
For how the SACL's labels and policies fit into the full check pipeline, read Access decisions.
To read and edit a SACL from a shell, read The sd command.
The sd command
Peios / Peios Security Fundamentals / Security Descriptors
sd is the command-line tool for working with security descriptors on files. Everything this topic describes — owners, DACLs, ACEs, the SACL, integrity labels, inheritance — sd is how you read it and change it from a shell.
sd subcommand path [arguments]
$ sd show ./report.txt
$ sd allow ./report.txt alice:read
$ sd owner ./report.txt BA
Where ls -l shows a file's owner and a summary, and cp --preserve carries a descriptor across, sd is the tool that edits the descriptor directly.
The subcommands #
| Group | Subcommand | Does |
|---|---|---|
| Inspect | show | Print the descriptor on a path. |
check | Simulate an access check against the path. | |
| DACL | allow | Add an allow rule for one or more principals. |
deny | Add a deny rule for one or more principals. | |
remove | Drop every DACL rule for the named principals. | |
| Auditing | audit | Add an audit rule to the SACL. |
unaudit | Drop every SACL rule for the named principals. | |
| Ownership | owner | Set the descriptor's owner. |
group | Set the descriptor's group. | |
| Integrity | integrity | Set the mandatory integrity label. |
| Inheritance | inherit | Turn inheritance protection on or off. |
reset | Drop the file's own rules and re-inherit from the parent. | |
propagate | Push inheritance down to descendants. | |
| Wholesale | set | Replace the entire descriptor at once. |
Naming a principal #
Wherever a subcommand takes a PRINCIPAL, it accepts any of:
| Form | Example | Meaning |
|---|---|---|
@self | @self | The user SID of the token running sd. |
@owner | @owner | A placeholder that the access check substitutes with the file's own owner. |
| A well-known label | Everyone, Administrators, LocalSystem | A named built-in principal. |
| A two-letter alias | WD, BA, SY | The short alias for a well-known principal. |
| A raw SID | S-1-5-32-544 | Any SID, written out in full. |
See SIDs for what these are.
Naming permissions #
Wherever a subcommand takes PERMS, several notations are accepted, and may be mixed:
| Form | Example | Meaning |
|---|---|---|
| Single letters | rwx, r, m | r read, w write, x execute, d delete, m modify, f full, c change-permissions, o take-ownership. |
| Words | read,write, modify | The same set, spelled out. |
| Fine-grained names | read-data,append,traverse | Individual low-level rights, for precise rules. |
| Raw hex | 0x1F01FF | An access mask written directly. |
Run letters together (rwx) or separate names with commas (read,write,execute).
Inspecting #
sd show #
Prints the descriptor on a path — the owner, the group, the DACL, the SACL.
$ sd show ./report.txt
| Flag | Effect |
|---|---|
--sddl | Render the descriptor as an SDDL string. |
--raw | Render SIDs in raw S-1-… form only. |
--label | Render SIDs as their labels where known. |
--all | Verbose — decode every flag and show raw masks alongside. |
--json | Emit JSON. |
sd check #
Simulates an access decision: "would this access be allowed?" — without performing it.
$ sd check ./report.txt write
$ sd check ./report.txt read --pid 4821 --explain
| Argument / flag | Effect |
|---|---|
PERMS | The access to test for. |
--pid PID | Check against process PID's token instead of your own. |
--explain | Show why the decision came out as it did — the rule-by-rule walk. |
sd check is the first thing to reach for when an access is denied and you do not know why. --explain walks the descriptor the same way the kernel does.
Changing the DACL #
The DACL is the list of allow and deny rules. These three subcommands edit it.
sd allow and sd deny #
Add an allow (or deny) rule for one or more PRINCIPAL:PERMS pairs.
$ sd allow ./report.txt alice:read bob:rw
$ sd deny ./report.txt Everyone:write
| Flag | Effect |
|---|---|
--flags LIST | ACE inheritance flags — CI container-inherit, OI object-inherit, NP no-propagate, IO inherit-only; none clears them. |
--if EXPR | Make it a conditional rule, applied only when EXPR is true. |
--replace | Drop any existing rules for this principal and kind first, instead of appending. |
--recursive, -r | Apply to every descendant of the path. |
Remember that a deny rule, when it matches, wins over any allow — see DACL evaluation.
sd remove #
Drops every DACL rule — allow and deny — for the named principals.
$ sd remove ./report.txt bob carol
| Flag | Effect |
|---|---|
--allow-empty | Permit the result to be a present-but-empty DACL, which denies everyone. Without this, sd refuses to produce one. |
--recursive, -r | Apply to every descendant. |
Auditing #
sd audit and sd unaudit #
The SACL holds audit rules — see The SACL. sd audit adds one; sd unaudit drops every SACL rule for the named principals.
$ sd audit ./secrets.db Everyone:write:failure
An audit spec is PRINCIPAL:PERMS:WHEN, where WHEN is success, failure, or both — which outcomes to log. sd audit takes the same --flags, --if, --replace, and -r options as sd allow.
Ownership #
sd owner and sd group #
Set the descriptor's owner or group SID.
$ sd owner ./report.txt alice
$ sd group ./report.txt Administrators
Both take a single PRINCIPAL and accept -r. Changing an owner is itself an access-controlled act — see Ownership.
Integrity #
sd integrity #
Sets the file's mandatory integrity label.
$ sd integrity ./report.txt high
The level is one of untrusted, low, medium, medium-plus, high, system, protected. The standard catalog is five levels (untrusted, low, medium, high, system); medium-plus (RID 8448) and protected (RID 20480) are non-standard Windows-compatibility levels — the kernel compares any S-1-16-<rid> numerically.
| Flag | Effect |
|---|---|
--policy BITS | The label's policy bits, comma-separated: NW no write-up, NR no read-up, NX no execute-up. |
--recursive, -r | Apply to every descendant. |
For what the label does, see Mandatory integrity control.
Inheritance #
sd inherit #
Turns inheritance protection on or off — the + mark ls -l shows.
$ sd inherit off ./report.txt # lock the file; stop inheriting
$ sd inherit on ./report.txt # let it inherit from its parent again
inherit on lets the file inherit rules from its parent directory. inherit off protects the file — it keeps its current rules and stops tracking the parent.
| Flag | Effect |
|---|---|
--strip-inherited | When turning protection off, also drop the inherited rules already on the file. |
--recursive, -r | Apply to every descendant. |
sd reset #
Drops the file's own explicit rules and rebuilds its DACL purely from what the parent directory hands down — returning the file to "inherits everything".
sd propagate #
Pushes this directory's inheritable rules down into its descendants, refreshing what they inherit. Use it after changing a directory's rules so the children pick up the change.
See Inheritance for the full model.
Replacing the whole descriptor #
sd set #
Replaces the entire descriptor in one step.
$ sd set ./report.txt 'O:BAG:BAD:P(A;;FA;;;BA)(A;;0x1200a9;;;BU)'
| Argument / flag | Effect |
|---|---|
SDDL | The new descriptor as an SDDL string. - reads it from standard input. |
--binary FILE | Instead of SDDL, read the raw descriptor bytes from FILE (- for standard input). |
--components LIST | Override which parts of the descriptor (owner, group, DACL, SACL) the operation writes. |
Common flags #
These apply across the subcommands:
| Flag | Effect |
|---|---|
--recursive, -r | Apply the change to every descendant of the path. |
--no-follow-symlinks, -P | Operate on a symbolic link itself, not the file it points to. |
--json | Emit JSON instead of human-readable output. |
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — or, for sd check, the access would be allowed. |
| non-zero | The operation failed, the path was unreachable, or — for sd check — the access would be denied. |
Privileges
Peios / Peios Security Fundamentals / Privileges
A privilege is a system-wide right carried on a token. Where group membership decides "is this principal allowed to do things by virtue of who they are", a privilege decides "is this principal allowed to do this specific operation, regardless of who they are". Loading a kernel module, taking ownership of an object, changing the system clock, reading any file for backup — each is gated by a specific privilege, granted to principals whose role legitimately requires it.
Privileges sit on the token alongside the user SID and group list, but they are evaluated separately from identity. The access check reads a privilege bitmask, not a group membership. An ACE in a DACL cannot match a privilege the way it matches a SID. Privileges are their own axis of authority.
What a privilege is #
A privilege has three properties:
- A name — a specific string like
SeBackupPrivilege. Most privilege names start withSeand end withPrivilege; the convention is shared across the catalog. - A LUID — a 64-bit identifier the kernel uses internally to reference the privilege. Privileges occupy specific bit positions in the 64-bit privilege bitmask on every token.
- A specific operation it gates — every privilege has a defined effect: which kernel operation, which AccessCheck pathway, which capability it controls.
Privileges are global. The catalog is fixed at build time; there is no API to define new privileges at runtime. A token either has a specific named privilege or does not. The number of distinct privileges in v0.20 is in the low tens.
Where privileges come from #
A token's privileges are decided at one moment: when the token is minted. That happens either:
- During boot, when the kernel constructs the SYSTEM token (which holds every privilege).
- When authd authenticates a principal and constructs their token, applying the privilege policy authd has loaded for the principal's role.
- When peinit mints a token for a service it is launching.
- When existing code creates a derived token via DuplicateToken (preserves privileges) or FilterToken (removes the listed privileges).
There is no path to gain a privilege after a token is minted. AdjustPrivileges can enable or disable a privilege the token already has, and can permanently remove a privilege, but cannot add one that was not present at creation. A token's privilege bitmask is at most what was put there at the start.
The privilege policy authd consults — which principals get which privileges — is local to the machine, and lives in the registry under Machine\Generic\Authn\Policy, one record per principal. authd reads it at every logon. See assigning privileges for the record format and the rules.
That it is local is the design rather than an implementation detail. A principal source — the local store, or a directory — says who someone is: their SID, their memberships, their POSIX identifiers. It never says how much this machine trusts them, and the protocol it speaks gives it no way to. So a directory can tell this machine that you are a member of Domain Admins; whether that membership carries SeLoadDriverPrivilege here is this machine's answer, not the directory's.
From the kernel's point of view the policy is invisible: it only ever sees the resulting token.
The four states of a privilege #
At any moment, each privilege on a token is in one of four states:
| State | Meaning |
|---|---|
| Absent | The privilege is not on this token. The token's bitmask has the relevant bit clear in both the "present" and "enabled" fields. |
| Present, disabled | The privilege is on the token but not currently in effect. The access check ignores it. The token may enable it via AdjustPrivileges. |
| Present, enabled | The privilege is on the token and is in effect. AccessCheck will use it where applicable. |
| Used | The privilege has been exercised at least once. A sticky audit bit; never cleared. |
The states layer rather than replace. A privilege that has been used remains in whatever present/enabled state it was in; the "used" bit is recorded alongside, for auditing.
Disabling rather than removing is the common pattern. A service runs most of the time with sensitive privileges present but disabled, enabling them only at the moment they need to be exercised and disabling them afterwards. The reason: the access check ignores disabled privileges entirely, so the service is not at risk of accidentally exercising a privilege it had not meant to use.
The full lifecycle — transitions, AdjustPrivileges semantics, FilterToken-based permanent removal, the "used" bit — lives in Privilege lifecycle.
Privileges in the access check #
Privileges interact with the access check in three ways:
Direct gating #
Some operations are gated directly by a privilege held at the moment of the call, with no DACL involved.
SeLoadDriverPrivilegegates kernel module loads. No DACL applies; either you have the privilege enabled or the load fails.SeSystemtimePrivilegegatessettimeofday.SeCreateTokenPrivilegegateskacs_create_token.
These privileges are direct. The check is "is this privilege enabled on the calling token" and nothing else.
Influencing the DACL walk #
A handful of privileges, when enabled, change what the DACL walk would conclude. These are the AccessCheck-influencing privileges, covered in Privilege categories:
SeSecurityPrivilegegrantsACCESS_SYSTEM_SECURITY(SACL read/write) regardless of the DACL.SeTakeOwnershipPrivilegegrantsWRITE_OWNERon any object.SeBackupPrivilegegrants read access to any object (with the backup intent flag set).SeRestorePrivilegegrants write access and ownership-change rights (with the restore intent flag).SeRelabelPrivilegepermits raising an object's integrity label above the caller's own.
These privileges produce grants that no DACL would have produced. They are recorded in the access check's audit state so audit events can record "this access was granted by privilege X" rather than appearing as a DACL grant.
Intent-gated privileges #
Two of the influencing privileges — SeBackupPrivilege and SeRestorePrivilege — only fire when the caller passes a specific intent flag to AccessCheck. Without the flag, the privileges might as well not be present. This avoids accidentally exercising a backup privilege when doing a normal read.
The intent model is covered in Intent-gated privileges.
Privileges vs groups #
The split between privileges and group membership is one of the deliberate design decisions of the model:
| Group membership | Privileges | |
|---|---|---|
| What it grants | Whatever ACEs name the group | A specific operation, named directly |
| Where it lives | The token's groups field | The token's privilege bitmask |
| How it is matched | ACE SID matches token group SID | Privilege code is consulted in specific kernel paths |
| Who manages it | Whoever holds the accounts (a directory, or lpsd) and the per-object administrator (DACL entries) | This machine's administrator, in local policy |
| Granularity | Group-wide; the same group across all objects | Per-operation; the same privilege across all objects |
A user can be in a group and still not hold a privilege the rest of the group has, or vice versa, because the two are managed independently — and, more than that, they are managed by different parties. Memberships come from whoever holds the account; privileges come from this machine.
The classical example: BUILTIN\Administrators is a group, and being in it grants whatever ACEs in DACLs name the group. But the dangerous privileges — SeLoadDriver, SeBackup, SeRestore — are not automatic by group membership. Local policy specifies which administrators get which privileges, and it does so by naming principals: a record for one account's SID can grant it privileges the rest of Administrators does not have, or withhold ones they do.
This separation matters because the failure modes are different. A misconfigured DACL grants too much group access; a misconfigured privilege policy grants too much system-level authority. Both are fixable in different places.
Privileges and inheritance #
Group memberships propagate down through identity (you remain in your groups until your token changes). Privileges work the same way: a token holds the privileges authd minted it with, and the bitmask is preserved through fork, exec, and inheritance into child processes — exactly like the rest of the token.
Privileges do not propagate to ACEs or objects. There is no "privilege-bearing" ACE; no SD can be annotated with a privilege requirement; no object can demand "you must have SeBackup to open me". The privilege model and the SD model do not overlap that way. They interact only through the access check, when an influencing privilege is enabled on the caller's token and the AccessCheck pipeline decides whether to use it.
Where to start #
If you want the lifecycle in detail — the present/enabled/used/removed transitions, what AdjustPrivileges actually does, how FilterToken removes a privilege permanently — read Privilege lifecycle.
If you want to understand intent-gated privileges — why SeBackup and SeRestore require an explicit flag and how that flag is passed — read Intent-gated privileges.
If you want to see how privileges are organised — the kernel-standalone group, the AccessCheck-influencing group, the application-level group, the reserved group — read Privilege categories.
If you want the full catalog of named privileges with their numeric LUIDs and one-line descriptions, that is in the Constants and catalogs reference.
Privilege lifecycle
Peios / Peios Security Fundamentals / Privileges
A privilege's state on a token is more than just "yes, the token has it". The four-state model — absent, present-disabled, present-enabled, used — exists so a service can hold a sensitive privilege most of the time without exercising it, enable it briefly when it actually needs it, disable it again afterwards, and have all of those transitions auditable.
This page covers each transition: how privileges get added (only one moment), how they get enabled and disabled (many times), how they get permanently removed (one-way), and what the "used" bit means for auditing.
The four states #
From the Privileges overview:
| State | Bitmask representation | Meaning |
|---|---|---|
| Absent | present bit clear | Not on the token. AccessCheck cannot use it. |
| Present, disabled | present set, enabled clear | On the token but not in effect. AccessCheck ignores it. |
| Present, enabled | present set, enabled set | In effect. AccessCheck will use it where applicable. |
| Used (overlaid) | used bit set, independent of the others | Has been exercised at least once. Sticky. |
The bitmask is a 64-bit word. Each privilege occupies a fixed bit position (its LUID determines which). The token actually carries four independent 64-bit words: present, enabled, enabled_by_default, and used. Most code only cares about the first two.
enabled_by_default records the initial state — what was enabled at token creation. The kernel uses it to implement the "reset to defaults" sentinel in AdjustPrivileges.
Adding a privilege: only at creation #
There is exactly one moment when a privilege can be added to a token: when the token is being created. Specifically:
- During boot, the kernel constructs the SYSTEM token with every privilege present and enabled.
- When authd calls
kacs_create_token, the wire-format specification names the privileges to include and which of them should start enabled. - When peinit calls
kacs_create_tokenfor a service.
DuplicateToken preserves the privilege bitmask of the source — every present bit, every enabled bit, every used bit copies into the new token. The duplicate has the same privileges as the source.
That is the entire list of paths. There is no syscall, no ioctl, no privileged API to add a privilege to an existing token. A token's present bitmask is at most what it was at creation; it can only shrink over time.
This rule is one of the load-bearing parts of the security model. It is what makes "the principal's authority is fully expressed by their token" true. If a process could acquire new privileges at runtime, the kernel would need to track which privilege-acquisition paths exist and protect each one. By making creation the only entry point and authd the only entity that can use it, the kernel concentrates the privilege-policy decision into one component, audited at one moment.
Enabling and disabling: AdjustPrivileges #
The most common runtime operation on privileges is enabling or disabling them. The syscall is AdjustPrivileges (also reachable as the KACS_IOC_ADJUST_PRIVS ioctl on a token fd).
The caller passes an array of kacs_priv_entry records, each containing:
- A LUID identifying which privilege to adjust.
- An attributes value telling the kernel what to do with it.
The attributes value is one of:
| Value | Effect |
|---|---|
0 | Disable the privilege. Clears the enabled bit; leaves present set. |
SE_PRIVILEGE_ENABLED (0x02) | Enable the privilege. Sets the enabled bit. The privilege must already be present. |
SE_PRIVILEGE_REMOVED (0x04) | Permanently remove the privilege. Clears present, enabled, and enabled_by_default. Does not clear used. Irreversible. |
KACS_PRIV_RESET_ALL_DEFAULTS (0x80000000) | Sentinel value, used with LUID 0. Resets every privilege on the token to its enabled_by_default state. |
The operation is atomic: every entry in the array is validated first, and if any one is invalid (a LUID for a privilege not present on the token, an unknown attributes value, a duplicate LUID), the entire call fails and no changes are made. There is no partial success.
The call returns the previous state of every privilege touched, as a bitmask. Callers that want to do scoped privilege enable — turn on a privilege, do work, turn it off — read the return value to know what state to restore to. The pattern:
- Save previous state by calling AdjustPrivileges to enable the desired privilege.
- Do the work.
- Call AdjustPrivileges again with the saved state to restore.
If the privilege was already enabled before step 1, step 3 leaves it enabled. If it was disabled, step 3 restores it to disabled. Either way the work runs with the privilege enabled and the original state is preserved.
What AdjustPrivileges cannot do #
AdjustPrivileges cannot:
- Add a privilege. A LUID for a privilege not present on the token is rejected. The token must already have the privilege; AdjustPrivileges only changes its enabled state or removes it.
- Resurrect a removed privilege. Once
SE_PRIVILEGE_REMOVEDis applied, the privilege is absent. A subsequent attempt to enable it fails (the privilege is no longer present). - Toggle the
usedbit. The kernel setsusedautomatically when a privilege is exercised. Userspace cannot clear it. - Set
enabledwithoutpresent. The kernel rejects this — a privilege cannot be enabled if it is not present on the token. The "enabled but absent" state is not representable.
The right to call AdjustPrivileges is gated by TOKEN_ADJUST_PRIVILEGES (0x0020) on the target token. A thread can always adjust its own token's privileges (the default token SD grants this right to the token's user identity); adjusting another process's token requires PROCESS_QUERY_INFORMATION on that process, TOKEN_ADJUST_PRIVILEGES on the token, and the appropriate PIP dominance.
The "used" bit and audit #
When AccessCheck or any other kernel path actually exercises a privilege on a token, the kernel sets the corresponding bit in the token's used bitmask. Once set, the bit remains set for the life of the token. Nothing — not AdjustPrivileges, not FilterToken, not DuplicateToken into a fresh token — clears it.
The bit is for auditing. It answers the question "did this token, at some point in its existence, exercise this privilege?". A security audit can read a token's used bitmask to see which privileges have been engaged so far. A privilege that is present but never used is materially different from one that has been used; the used bit records the difference.
This is why removal does not clear used. If a privilege has been exercised, removing it later does not erase the fact. The audit trail remains.
DuplicateToken copies used from the source. A duplicate of a token that has used a privilege is itself marked as having used it, even though the duplicate has never actually exercised it. The rationale: a duplicate inherits the source's audit history; if the source has used a privilege, anything derived from it is suspect of having access to that privilege's effects.
If you need a fresh, audit-clean token, you need a freshly-minted token (a new authentication, or a new kacs_create_token call). Duplicates carry history.
Permanent removal #
SE_PRIVILEGE_REMOVED permanently removes a privilege from a token. After removal:
presentis clear.enabledis clear.enabled_by_defaultis clear.usedremains whatever it was (still set if the privilege was exercised before removal).
The removal is irreversible. There is no syscall to re-add. The only way back to a token with the privilege is to start with a different token — typically by FilterToken from a source that still has the privilege, or by a fresh authentication.
Removal is the right tool for:
- Service hardening. A service that holds privileges by default but only needs them at startup can remove them after the startup phase. Removing rather than disabling makes the privilege unrecoverable for the remainder of the service's lifetime — even if an attacker compromises the service later, the privileges are gone.
- Sandbox launchers. Code that forks a child to run untrusted work removes every dangerous privilege from the child's token before exec. The child has no way to get them back.
- One-way demotion. A process starting with high authority that wants to demote itself to a lower-privileged identity, irrecoverably, removes the privileges it no longer wants. This is stronger than disabling: a disabled privilege can be enabled by code running on the token (assuming
TOKEN_ADJUST_PRIVILEGES); a removed privilege cannot.
The one-way nature is the point. The model is built around the assumption that authority shrinks. A removed privilege is gone for the same reason a disabled group can be marked use-for-deny-only and not un-marked: each is a deliberate restriction the kernel will not let userspace undo.
FilterToken and bulk removal #
For removing multiple privileges at once — typically during sandbox creation — FilterToken is the appropriate API. Where AdjustPrivileges with SE_PRIVILEGE_REMOVED removes one privilege from the calling token, FilterToken produces a new token with the listed privileges removed.
The difference matters: FilterToken does not modify an existing token. It creates a copy with the listed adjustments (privileges removed, groups marked use-for-deny-only, restricted SIDs added) and returns the new token. The original is unchanged.
This is the right pattern for sandbox launchers, which want to filter a token down to a restricted version for a child process without affecting their own running identity. AdjustPrivileges with SE_PRIVILEGE_REMOVED is the right pattern for code that wants to demote itself.
FilterToken is covered in Token lifecycle and Restricted and write-restricted tokens.
The reset-to-defaults sentinel #
A token records enabled_by_default separately from enabled. The two diverge as the token's privileges get enabled and disabled at runtime; the default record stays as it was at creation.
The KACS_PRIV_RESET_ALL_DEFAULTS sentinel — a kacs_priv_entry with luid = 0 and the sentinel attributes value — tells AdjustPrivileges to copy enabled_by_default back into enabled. Every privilege that was enabled at creation is now enabled again; every privilege that was disabled at creation is now disabled again.
The sentinel is useful for code that does "enable a few privileges, do work, restore". Instead of saving the prior state and restoring it explicitly, the code can simply reset to defaults — which is the right behaviour as long as nothing else has changed the defaults (which nothing should, since defaults are immutable at creation).
The reset sentinel only affects enabled. It does not bring back removed privileges. It does not clear the used bit. It is a cheap way to "go back to how this token started" within the set of operations that still make sense.
Field mutability summary #
To pin the entire model on one table:
| Field | Mutability |
|---|---|
present | Only ever clears (via SE_PRIVILEGE_REMOVED). Set only at token creation. |
enabled | Toggled freely between 0 and the corresponding present bit. |
enabled_by_default | Immutable after token creation. |
used | Set by the kernel when the privilege is exercised. Never clears. |
The asymmetric mutability is the model's spine. Authority can decrease at runtime but not increase. State for auditing accumulates. Defaults are fixed. The "easy" mutation — toggle enabled — is the only one that goes both ways, and it does not affect what the token fundamentally is.
Where to go next #
For the two privileges that require an intent flag on top of being enabled, read Intent-gated privileges.
For bulk privilege removal as part of building a sandbox token, read Restricted and write-restricted tokens.
Intent-gated privileges
Peios / Peios Security Fundamentals / Privileges
Most privileges work the same way: if they are enabled on the calling token at the moment of the call, the kernel uses them; if not, it does not. AdjustPrivileges manages enabled state; the access check reads it. There is no other input.
Two privileges are special: SeBackupPrivilege and SeRestorePrivilege. Both are present-and-enabled on the tokens of backup-and-restore tools. Both grant access that the DACL alone would not. And both will refuse to do anything unless the calling code passes a specific intent flag to AccessCheck — BACKUP_INTENT for SeBackup, RESTORE_INTENT for SeRestore.
Without the intent flag, the privilege is invisible to the access check. The token has it. The kernel knows it has it. But the privilege does not participate in the access decision. The DACL walk runs as if the privilege were not present.
This page covers why intent gating exists, how the flags are passed, and what each privilege actually does when its intent flag is set.
Why intent gating exists #
The privileges in question grant very broad access. SeBackup grants read on any object regardless of the DACL. SeRestore grants write, ownership change, and SACL access on any object. They are designed for backup-and-restore tools — programs that legitimately need to bypass discretionary access control to do their job.
The problem: if these privileges were always in effect when enabled, every access check on a token holding them would benefit from them. A backup tool that opens a configuration file as part of its initialisation — for entirely ordinary reasons — would silently exercise SeBackup, even though the configuration file's normal DACL would have granted the access anyway. The audit trail would record privilege exercises that were operationally meaningless. Worse, a buggy code path in the tool that did something unintended would silently get backup-level access to the unintended object.
Intent gating solves this by making the privilege opt-in per call. The tool's main backup loop sets BACKUP_INTENT on every AccessCheck call it makes; everything else does not. The privilege fires only where the tool deliberately asked for it. Audit events for privilege exercise are accurate (they only appear where the tool intentionally invoked the privilege), and bugs in non-backup code paths cannot accidentally exercise SeBackup.
The split is between "enabled" (the token can use the privilege) and "intent" (the caller wants to use the privilege right now). Both must be true.
The intent flags #
The flags are passed to AccessCheck as part of the privilege_intent parameter. They are independent bits:
| Flag | Effect |
|---|---|
BACKUP_INTENT (0x01) | Tells AccessCheck that the caller wants SeBackupPrivilege to participate, if present and enabled. |
RESTORE_INTENT (0x02) | Tells AccessCheck that the caller wants SeRestorePrivilege to participate. |
A caller can set both, one, or neither. The flags are not exclusive — a backup-and-restore tool might do a restore-with-verification operation that wants both privileges to fire.
Without either flag, the corresponding privilege is stripped from the access check's view of the token for this call. It is not removed from the token; the token still has it; the next call from the same code can set the flag and use it. The strip happens only for the current AccessCheck.
The flags are at the AccessCheck API surface. Lower-level open and access paths (the file system's open, the registry's read) translate higher-level semantics into AccessCheck calls; whether they pass the flags depends on whether they were told to. Most do not; an opener of a file in the ordinary course of work does not pass BACKUP_INTENT.
What SeBackupPrivilege does #
With BACKUP_INTENT set and SeBackup enabled, AccessCheck grants the caller read-category rights on the object regardless of the DACL:
- For files:
FILE_READ_DATA,FILE_READ_ATTRIBUTES,FILE_READ_EA, andREAD_CONTROL. - For registry keys: the read-category rights on the key (the specific values depend on the registry's GenericMapping).
- For tokens:
TOKEN_QUERYandREAD_CONTROL.
The grant happens after the DACL walk has run. Specifically, the walk produces a granted mask for whatever the DACL alone would grant; the privilege then adds the read-category bits to that mask without consulting the DACL. If the DACL already granted some of them, the privilege grant is redundant; if the DACL granted nothing, the privilege adds them.
The grant is recorded in the access check's audit state. An audit event for this call records "the read rights came from SeBackupPrivilege", distinguishing privilege-granted access from DACL-granted access. Audit consumers that care about privilege use can filter by this marker.
SeBackup does not grant write access. A backup tool that needs to write its output also reads but cannot use SeBackup for that — write is SeRestore's territory.
What SeRestorePrivilege does #
With RESTORE_INTENT set and SeRestore enabled, AccessCheck grants the caller write-category rights and metadata-modification rights regardless of the DACL:
- Write rights (
FILE_WRITE_DATA,FILE_APPEND_DATA,FILE_WRITE_ATTRIBUTES,FILE_WRITE_EAfor files; corresponding rights on other object types). DELETE.WRITE_OWNERandWRITE_DAC.ACCESS_SYSTEM_SECURITY(SACL read/write).
Plus, separately, SeRestore bypasses the "new owner must be self or SE_GROUP_OWNER group" restriction during kacs_set_sd. A restore tool can set the owner of a restored object to any well-formed SID, not just the caller's own. This is what lets a backup restore reconstitute an object's original owner even when the original principal is not present on the running system.
Like SeBackup, the grant is recorded in audit state — write rights granted by SeRestore are distinguished from those granted by the DACL.
The reason SeRestore also grants ACCESS_SYSTEM_SECURITY (which would normally require SeSecurityPrivilege) is the same: a restore operation needs to set the SACL as part of reconstituting the object's policy. Forcing the tool to also hold SeSecurity would be redundant; SeRestore folds that authority in.
What the flags do not do #
Two clarifications:
- The flags do not grant privileges. A token that does not have SeBackup gets nothing from setting BACKUP_INTENT. The flag tells the kernel "use this privilege if I have it"; it cannot conjure a privilege the token lacks.
- The flags do not enable disabled privileges. A token that has SeBackup present but disabled gets nothing from BACKUP_INTENT, just as it would get nothing without the flag. The privilege must be enabled for AccessCheck to consider using it. Intent is on top of enabled, not in place of it.
The state machine is: token has the privilege AND token has the privilege enabled AND caller has set the intent flag = AccessCheck uses the privilege. Any one of the three missing means it does not.
Why only these two privileges #
The intent-gating model exists specifically for privileges that grant broad, blanket access. SeBackup grants read on everything; SeRestore grants write on everything. The risk of accidental exercise is high enough to be worth a per-call gate.
Other privileges that influence the access check — SeSecurityPrivilege (SACL access), SeTakeOwnershipPrivilege (WRITE_OWNER), SeRelabelPrivilege (raising integrity) — are not intent-gated. They are scoped enough that the "just check if enabled" model is appropriate. SeSecurity only fires when accessing the SACL; SeTakeOwnership only when changing the owner; SeRelabel only when changing the integrity label — none of which is done by accident.
The split is between "this privilege only fires when you do something specific anyway" (no intent needed) and "this privilege would fire on every access if we let it" (intent required). SeBackup and SeRestore are the only two privileges in the second class.
Calling pattern #
A typical backup tool's loop:
- Token at startup has
SeBackupPrivilegepresent and enabled. (Granted by authd's privilege policy to backup-role principals.) - For each object to back up:
- Call AccessCheck with
BACKUP_INTENTset inprivilege_intent. The kernel grants the read-category rights via SeBackup if the DACL would not. - Read the object.
- Call AccessCheck with
- Other operations the tool does (reading its own configuration, writing log output, opening its output file) call AccessCheck without
BACKUP_INTENT. These run through the DACL like any other access. The privilege is not exercised.
A typical restore tool's loop is the mirror:
- Token at startup has
SeRestorePrivilegepresent and enabled. - For each object to restore:
- Call AccessCheck with
RESTORE_INTENTset. The kernel grants write-category rights via SeRestore. - Write the object's contents.
- Set its SD via
kacs_set_sd, including owner. The "any well-formed SID can be the owner" rule is in effect because RESTORE_INTENT was set on the AccessCheck that produced the WRITE_OWNER grant.
- Call AccessCheck with
- Other operations run normally without the flag.
The pattern is symmetric. Both privileges work the same way; both follow the same intent rule.
What about privileges in audit events #
Audit events for privilege exercise carry enough information to distinguish:
- The privilege that was exercised.
- The bits it contributed to the final granted mask.
- Whether those bits survived to the final access decision (i.e., were not stripped by some later layer like restricted-token intersection or confinement).
A backup tool with BACKUP_INTENT set on every AccessCheck call produces clean audit events: one privilege-use event per backup-flavoured access, none for incidental accesses. This is the audit trail intent gating was designed to produce. Without the flag, the privilege would fire on every access from the same token, and the audit would be unable to distinguish "I exercised SeBackup because I'm a backup" from "I exercised SeBackup because the file would have been readable to me anyway".
The full audit model lives in Auditing.
Where to go next #
For the four functional categories the rest of the privileges fall into, read Privilege categories.
For exactly where SeBackup and SeRestore fire during a check — and which layers can still strip their grants — read Privileges in the pipeline.
Privilege categories
Peios / Peios Security Fundamentals / Privileges
The privileges in Peios fall into four functional categories. Each category has a different relationship to the kernel and to the access check. Knowing which category a privilege is in tells you what it does, where it fires, and whether to expect it to participate in the DACL walk.
This page is organised around the four categories. The full per-privilege catalog — name, LUID bit, one-line description for every privilege — is in Constants and catalogs.
The four categories #
| Category | What the privileges do | Examples |
|---|---|---|
| Kernel-standalone | Gate specific kernel operations directly. Not consulted during the DACL walk. | SeLoadDriverPrivilege, SeSystemtimePrivilege, SeCreateTokenPrivilege |
| AccessCheck-influencing | Cause the access check to grant access the DACL alone would not. Consulted at specific points during the access pipeline. | SeSecurityPrivilege, SeTakeOwnershipPrivilege, SeBackupPrivilege, SeRestorePrivilege, SeRelabelPrivilege |
| Application-level | Defined for the directory and authd to interpret. The kernel records them on the token but does not enforce them itself. | SeSyncAgentPrivilege, SeEnableDelegationPrivilege, SeMachineAccountPrivilege |
| Reserved | Present in the catalog for ABI parity but not used in v0.20. | SeCreatePagefilePrivilege, SeUndockPrivilege, SeTimeZonePrivilege |
The categorisation is functional, not structural. A token's privilege bitmask does not partition by category; all bits live in the same 64-bit word. The category is a property of how each individual privilege is consumed.
Kernel-standalone privileges #
These are the largest category. A kernel-standalone privilege gates a specific kernel operation: the kernel checks at the entry point whether the caller's effective token has the privilege enabled, and refuses the operation if not. The DACL walk is not involved.
Representative members:
| Privilege | What it gates |
|---|---|
SeCreateTokenPrivilege | kacs_create_token. Token minting. Held only by authd and peinit. |
SeAssignPrimaryTokenPrivilege | Installing a token as another process's primary. Used by peinit. |
SeImpersonatePrivilege | Impersonating any user (when not running as the same user). Held by every service that handles user requests. |
SeTcbPrivilege | "Act as part of the TCB" — a catch-all for operations that should only happen in trusted code. Required for KACS_IOC_LINK_TOKENS, kacs_set_caap, mount-policy changes (policy=synth-*, which author security descriptors), and a handful of other system operations. It also satisfies every check SeManageVolumePrivilege satisfies, since the TCB may do anything a volume manager may. |
SeLoadDriverPrivilege | Loading and unloading kernel modules. Held only by peinit on its primary token; explicitly stripped via FilterToken from every other service. |
SeManageVolumePrivilege | Mounting, unmounting and reshaping the mount tree, including mount policy (policy=synth-*). Granted to Administrators. The most powerful privilege routinely granted outside the TCB — see the warning below. |
SeShutdownPrivilege | Local shutdown and reboot. |
SeRemoteShutdownPrivilege | Shutdown from a remote connection. Requires SeShutdown as well. |
SeDebugPrivilege | Inspecting another process regardless of its SD. Crucially, it does not bypass PIP dominance — a SeDebug holder can bypass an unrelated process's SD but still cannot cross a PIP boundary. |
SeSystemtimePrivilege | Setting the system clock. |
SeIncreaseBasePriorityPrivilege | Raising another process's scheduling priority or setting its CPU affinity. |
SeIncreaseQuotaPrivilege | Overriding resource limits for a process. |
SeLockMemoryPrivilege | Locking pages in physical memory (mlock/mlockall). |
SeAuditPrivilege | Writing entries to the audit log. |
SeProfileSingleProcessPrivilege | Cross-task profiling of a specific other process (perf_event_open). Own-task profiling needs no privilege. |
SeSystemProfilePrivilege | System-wide profiling (perf_event_open with pid == -1): per-CPU, all-task, kernel-mode events. |
SeBindPrivilegedPortPrivilege | Binding to TCP/UDP ports below 1024. A Peios-specific privilege. |
SeChangeNotifyPrivilege | Bypassing traverse checks during path resolution. Granted to every principal by default; rarely the answer to a question. |
SeCreateSymbolicLinkPrivilege | Creating symbolic links. Granted to every principal by default. |
For all of these, the calling pattern is the same: the kernel reads the calling token's privilege bitmask at the entry point of the gated operation; if the privilege is not enabled, the operation fails with the appropriate error.
The privileges in this category are mostly held in narrow ways. The most dangerous of them — SeCreateToken, SeTcb, SeLoadDriver — appear on only a handful of TCB token holders. Most other services hold a small, role-specific subset.
AccessCheck-influencing privileges #
These privileges, when enabled, change what the access check itself decides. They produce grants the DACL alone would not.
| Privilege | What it does |
|---|---|
SeSecurityPrivilege | Grants ACCESS_SYSTEM_SECURITY on any object (SACL read/write). Also gates kernel-standalone audit-system operations. |
SeTakeOwnershipPrivilege | Grants WRITE_OWNER on any object regardless of the DACL. Subject to MIC and PIP — does not bypass those. |
SeBackupPrivilege | Grants read-category rights on any object when BACKUP_INTENT is set. Intent-gated. |
SeRestorePrivilege | Grants write, metadata, and ownership-change rights on any object when RESTORE_INTENT is set. Intent-gated. Also bypasses the "new owner must be self or SE_GROUP_OWNER group" restriction. |
SeRelabelPrivilege | Permits setting an object's mandatory integrity label above the caller's own. Also acts as the kernel-standalone gate for kacs_set_sd with LABEL_SECURITY_INFORMATION set to a higher label. |
These privileges fire at specific points in the access pipeline:
SeSecurityPrivilegeis consulted when AccessCheck seesACCESS_SYSTEM_SECURITYin the requested mask.SeBackupPrivilegeandSeRestorePrivilegeare consulted near the start of AccessCheck, but only if the corresponding intent flag is set inprivilege_intent. See Intent-gated privileges.SeTakeOwnershipPrivilegeis consulted after the DACL walk: if the walk did not grantWRITE_OWNERand the mandatory policy did not block it, the privilege grants it.SeRelabelPrivilegeis consulted bykacs_set_sdwhen the caller is trying to set an integrity label.
When any of these privileges contributes to the final granted mask, the access check records the fact so audit events can attribute the grant correctly. Audit consumers can distinguish "the user got read access because the DACL allowed it" from "the user got read access because they hold SeBackup and asked to use it".
Application-level privileges #
These privileges are defined for authd, the directory, and federation services to interpret. The kernel records them on the token (so authd has somewhere to put them) but does not enforce them itself.
| Privilege | What it does |
|---|---|
SeSyncAgentPrivilege | Lets the holder read all directory objects regardless of per-object permissions. Used by directory replication agents. |
SeEnableDelegationPrivilege | Lets the holder mark a principal as trusted for delegation in the directory. |
SeMachineAccountPrivilege | Lets the holder add computer accounts to the domain. |
From the kernel's point of view, these are token attributes that no kernel path checks. They appear on the token's privilege bitmask; AdjustPrivileges can enable, disable, or remove them like any other privilege; but no AccessCheck path consults them. Their effect happens entirely in user-space services that read the token and act on its privilege bitmask themselves.
The kernel still enforces the present/enabled/removed/used state machine for these privileges as it does for others — AdjustPrivileges treats them identically. The application-level distinction is about who consumes them, not how they are stored or transitioned.
What SeManageVolumePrivilege is actually worth #
Mounting is an administrative act rather than a TCB one, which is why this
privilege exists separately: without it no administrator could mount anything,
and peios-install could not run outside a SYSTEM shell.
But be clear about its reach before granting it more widely. Its holder may:
- mount a filesystem whose synthesised security descriptors it chooses
(
policy=synth-*with--synth-sddl), and - mount over an existing path.
Together those are enough to author policy on a subtree and to shadow a system path — so in the hands of a determined holder it is a route to authority approaching the TCB's.
This is a deliberate design decision, not an oversight. The narrower
alternatives — permitting only filesystems that carry their own descriptors, or
constraining synthesis to a fixed system template — were considered and
rejected, because the FAT ESP carries no descriptors of its own and must be
mounted synth-ephemeral for an installation to work at all.
Treat SeManageVolumePrivilege as sitting beside SeLoadDriverPrivilege in
sensitivity, not beside SeChangeNotifyPrivilege.
Mounting passes three separate checks, and the privilege covers all
three: may_mount() (may I reshape my namespace), mount_capable() (may
this context create a superblock), and KACS's set_mount_policy (may I
choose how descriptors are synthesised). They were found one at a time by
driving a real image — each fix moved the failure to the next gate — which
is worth knowing if a fourth ever appears.
Reserved privileges #
A handful of privileges appear in the catalog for binary compatibility with the spec lineage but have no implementation in v0.20:
| Privilege | Why it is reserved |
|---|---|
SeCreateGlobalPrivilege | No per-session object namespaces in Peios. |
SeCreatePagefilePrivilege | Folded into SeTcbPrivilege. |
SeCreatePermanentPrivilege | No Linux equivalent. |
SeIncreaseWorkingSetPrivilege | Linux does not gate memory-residency hints. |
SeTrustedCredManAccessPrivilege | Reserved for future secrets infrastructure. |
SeSystemEnvironmentPrivilege | Replaced by SDs on EFI variable files under FACS. |
SeTimeZonePrivilege | Linux does not gate timezone changes. |
SeUndockPrivilege | Server OS; not applicable. |
A reserved privilege's LUID position in the bitmask is allocated, but no kernel path consults it. They are placeholders that keep the bitmask layout stable for future use. A reserved name is not in the privilege vocabulary at all, so a policy record naming one is dropped with a warning rather than granting anything.
Default-grant privileges #
Two privileges deserve a special note: SeChangeNotifyPrivilege and SeCreateSymbolicLinkPrivilege are granted to every principal on a stock machine. The reason is that they are needed for almost every program to function normally — without SeChangeNotifyPrivilege, a process cannot traverse a directory to reach a file, so a token lacking it cannot so much as start a shell; without SeCreateSymbolicLinkPrivilege, a process cannot create the symlinks that build systems and packaging tools depend on.
They are granted by the shipped policy — a record for Everyone — rather than being built into authd, so both are visible and both can be taken away. See assigning privileges. SeChangeNotifyPrivilege alone is additionally authd's compiled floor, applied when a machine has no policy key at all, because a machine that cannot start a shell cannot be repaired from a console.
Their effect is broad-but-uninteresting: on a stock machine every token has them, so any reasoning about access that does not explicitly involve their absence can ignore them. They are mentioned here for completeness; they will rarely be the answer to a question about who can do what.
A token can have these stripped by FilterToken if a sandbox wants to operate without them. Doing so creates a token that cannot traverse arbitrary directories — useful in a tightly confined sandbox, not useful much elsewhere.
How to find the catalog #
The four-category model on this page is the conceptual structure. The byte-level catalog — every privilege name, its LUID bit position, its one-line effect — lives in Constants and catalogs. Cross-reference between the two when you need to look up a specific privilege.
The naming convention is uniform: every privilege starts with Se and ends with Privilege. The middle is descriptive: SeLoadDriver, SeBackup, SeChangeNotify. There are no privileges outside this convention.
Where to go next #
For the per-privilege reference — every name, LUID bit position, and one-line effect — see the Privilege catalog.
For how the AccessCheck-influencing category actually participates in a check, read Access decisions.
Assigning privileges
Peios / Peios Security Fundamentals / Privileges
A principal source says who someone is — their SID, their memberships, their POSIX identifiers. It never says how much this machine trusts them. Privileges and integrity are local policy: authd's alone, decided here, and there is no message with which a source could ask for one.
That policy lives in the registry:
Machine\Generic\Authn\Policy
authd reads it at every logon, not once at startup. A policy change takes effect the next time someone signs in, rather than the next time you restart the one daemon on the system that is most disruptive to restart.
One record per principal #
Each subkey is a principal, and holds everything this machine grants them:
Machine\Generic\Authn\Policy
DeniedPrivileges REG_MULTI_SZ ["SeDebugPrivilege"]
\Everyone
Privileges REG_MULTI_SZ ["SeChangeNotifyPrivilege"]
\Administrators
Privileges REG_MULTI_SZ ["SeBackupPrivilege", "SeRestorePrivilege"]
Integrity REG_SZ "High"
Owner REG_SZ "Administrators"
DefaultDacl REG_SZ "D:(A;;GA;;;SY)(A;;GA;;;BA)"
Keyed by principal rather than by privilege on purpose. One key shows the totality of a principal's authority — reg ls on \Administrators answers "what can an administrator do on this machine?" completely. Authority scattered across twenty per-privilege values is authority nobody audits: a right granted somewhere unexpected does not surface when you look at the principal, and you would have to know to check every other place.
It is also the only shape that holds more than privileges. Integrity, the default owner and the default DACL live on the same record, and logon rights will when they arrive.
Naming a principal #
A subkey's name is either a well-known name or a literal SID:
\Administrators
\S-1-5-21-2847362817-1094533892-3310298447-1000
Names are matched case-insensitively. The recognised ones are SYSTEM, Everyone, Authenticated Users, Administrators, Users, Guests, Local Service, Network Service, and the logon types Interactive, Network, Batch, Service and Anonymous.
Two limits are worth knowing before you hit them:
BUILTIN\Administrators cannot be a subkey name. A backslash is the registry's path separator, so the qualified spelling is unrepresentable. Write the bare name.
A local group's name does not resolve. authd cannot know what developers means without asking lpsd, and policy must never depend on a principal source being up — that would make "what may this principal do" unanswerable exactly when a source is broken. Name local groups by SID, which lps group list will show you.
A subkey that is neither a known name nor a parseable SID is ignored with a warning. It is worth checking for that warning after editing: a record naming nobody sits in the key looking authoritative and applying to no one.
If the key exists, the key is the whole policy #
This is the most important behaviour on the page.
authd carries a compiled-in floor, but it applies only when the key is absent entirely. It is not merged in value by value. So a policy you write is the complete policy: anything not granted below is not granted.
| State | What a principal gets |
|---|---|
| Key absent | The compiled floor — Everyone gets SeChangeNotifyPrivilege, and nothing else |
| Key present | Exactly what the records say |
| Key present but unreadable | Nothing, and a loud log line |
The alternative — a compiled default that each record replaces — fails in the wrong direction. An administrator who writes one record believing they have locked the machine down would still be handing out whatever a compiled table said for every principal they did not mention, from a table they cannot read. That is a security failure, and a silent one. Failing towards less privilege makes things stop working, which is far easier to diagnose than authority you did not know you were granting.
An unreadable key is treated as granting nothing rather than as absent, for the same reason: reading it as absent would silently restore every privilege an administrator may have deliberately removed, at the one moment nobody can check.
What a machine ships with #
The defaults are registry data, not compiled in — authd-policy.reg, shipped to /usr/share/regim/ and applied if the image opts in. So they can be read, edited, and replaced wholesale by an image that wants a different policy, rather than being invisible inside a binary.
A stock machine grants:
| Principal | Privileges | Integrity |
|---|---|---|
Everyone | SeChangeNotifyPrivilege, SeCreateSymbolicLinkPrivilege | (Medium, by default) |
Administrators | the operational set below, plus both of the above | High |
The operational set is SeBackupPrivilege, SeRestorePrivilege, SeShutdownPrivilege, SeRemoteShutdownPrivilege, SeSystemtimePrivilege, SeSecurityPrivilege, SeLoadDriverPrivilege, SeImpersonatePrivilege, SeIncreaseQuotaPrivilege, SeIncreaseBasePriorityPrivilege and SeProfileSingleProcessPrivilege.
SeDebugPrivilege is deliberately not granted. It writes to any process regardless of integrity label, so it is the single privilege that most directly defeats the integrity boundary, and ordinary administration does not need it. Add it when a machine genuinely does.
Three privileges are never granted by the shipped policy and should not be added: SeCreateTokenPrivilege mints any identity without going near authd, which would make every other control here decorative; SeTcbPrivilege and SeAssignPrimaryTokenPrivilege belong to the trusted computing base.
Note that Administrators is granted SeChangeNotifyPrivilege directly, not only through Everyone. Privileges accumulate across every SID on a token, so that repetition is insurance: if someone edits the key and drops the Everyone record, ordinary users lose the ability to traverse a directory — but administrators keep working and can repair it.
How the values compose #
Privileges accumulate. A token holds the union of every record naming a SID it carries — the principal's own, and each of their groups. A group marked USE_FOR_DENY_ONLY contributes nothing.
DeniedPrivileges wins. Listed on the Policy key itself rather than on a record, it is applied after the union, so no record you have not read can defeat it. It is on the parent key so it cannot collide with a principal who happens to be called Denied.
Integrity, Owner and DefaultDacl are single values, so they cannot accumulate. The principal's own record wins outright; otherwise the groups decide:
- Integrity takes the maximum across the groups that name one.
OwnerandDefaultDaclhave no ordering to compare, so two groups naming different values is a misconfiguration: it logs a warning and falls back to the default rather than picking by whichever the registry enumerated first. Two groups naming the same value is not a conflict.
The user's record winning outright is what makes a principal possible to lower. Under a plain maximum, a guest whose own record said Low would still come out Medium the moment any group they belonged to named Medium. The consequence is worth stating plainly: a group cannot impose an integrity floor on a member. If Administrators names High and a member's own record names Low, that member gets Low.
Writing each value #
Privileges — REG_MULTI_SZ #
Full ABI names, SeBackupPrivilege rather than SeBackup, matched case-sensitively. A name this build does not recognise is dropped with a warning and the rest of the list still applies — a policy written for a newer Peios should not lose the privileges it spelled correctly.
An empty list is meaningful: it grants nothing, and is different from the value being absent.
Integrity — REG_SZ or REG_DWORD #
A tier name, matched case-insensitively:
| Name | Value |
|---|---|
Untrusted | 0 |
Low | 4096 |
Medium | 8192 |
High | 12288 |
System | 16384 |
Or a raw REG_DWORD. The kernel compares integrity numerically and any value is legal, so the numeric form reaches levels between the tiers — 8193 sits just above Medium. Use the name unless you need that.
A principal no record names gets Medium. That default is compiled in and is not affected by the key existing, because unlike a privilege it is not a grant: every token must carry some level to be valid at all.
Owner — REG_SZ #
Which principal owns objects this token creates. Names a principal, exactly like a subkey name does; authd converts it to the index the token actually carries, which is a number meaningful only within one token and different on the next logon.
Absent — the ordinary case — means objects are owned by their creator.
The case this exists for is a shared administrative estate: objects an administrator creates being owned by Administrators rather than by the individual, so they remain manageable when that person's account goes away.
DefaultDacl — REG_SZ #
The DACL objects this token creates inherit when nothing else supplies one, written as SDDL:
D:(A;;GA;;;SY)(A;;GA;;;BA)
SDDL rather than raw bytes because the whole argument for policy living in the registry is that an operator can read it. Conditional ACEs are preserved, so D:(XA;;GA;;;WD;(@USER.Department == "Engineering")) works and keeps its condition.
A value that does not parse is dropped with a warning and the system default applies — a typo costs the customisation, not the session.
Locking a machine down #
To forbid a privilege regardless of what any record says:
Machine\Generic\Authn\Policy
DeniedPrivileges REG_MULTI_SZ ["SeDebugPrivilege", "SeLoadDriverPrivilege"]
That is one edit, in one place, that a record you have not read cannot defeat. Removing the privilege from each record individually relies on having found them all.
What this cannot express yet #
Whether a principal may sign in at all. Policy decides what a session gets, not whether one happens. lps disable covers the blunt case; per-logon-type restriction — allowed over the network but not at the console — is not built.
Two privileges the kernel enforces but nothing can name. SeTakeOwnershipPrivilege and SeRelabelPrivilege are honoured by the access check and appear in audit records, but are absent from the published ABI, so no policy can grant them. SeSystemProfilePrivilege is in the same position on current builds. Until that is resolved, a Peios machine has no way to grant take-ownership — which means the documented escape hatch for a file whose DACL excludes you is not currently reachable.
Seeing what a token actually got #
$ token show --all
[privileges] lists what the token holds and integrity shows the label. A privilege the tool cannot name appears as <privilege bit N> rather than being omitted, so the list is always complete even when the name table is not.
See also #
- Privileges — the model these records feed.
- The token command — reading the privileges and integrity a live token actually carries.
- Managing local principals — the accounts these records apply to.
Access decisions
Peios / Peios Security Fundamentals / Access Decisions
AccessCheck is the function the kernel calls every time something is about to happen to a protected object. The caller has a token; the object has a security descriptor; the caller wants some set of rights. AccessCheck takes all three and produces an answer: which of those rights are granted, which are not, and whether to fire any audit events as a result.
The function is not a single comparison. It is a pipeline of layers, each of which can constrain the result. A right that the DACL would have granted can be removed by a later layer (restricted-token intersection, confinement, central access policy). A right that the DACL would not have granted can be added by an earlier layer (privilege grants). Following an access decision through is following each layer in order.
This page walks the pipeline from start to finish, names every stage, and points each one at the topic that covers it in depth.
The shape of an access check #
flowchart LR
A["Token (caller)"] --> X["AccessCheck"]
B["Security descriptor (object)"] --> X
C["Desired mask (caller asked for)"] --> X
D["Optional inputs: privilege intent, self SID, local claims, object type list, PIP from PSB"] --> X
X --> R["Granted mask + audit emissions + continuous audit mask + staging mismatch flag"]
Inputs:
| Input | Source | What it contributes |
|---|---|---|
| Token | The calling thread's effective token. | Identity (user SID, groups, restricted SIDs), token-level state (integrity level, mandatory policy, privileges, confinement). |
| Security descriptor | The object being accessed. | Owner, primary group, DACL, SACL — everything the policy on the object says. |
| Desired mask | The caller. | The 32-bit access mask of rights the caller wants. May include MAXIMUM_ALLOWED to ask "what could I have?". |
privilege_intent | The caller. | Flags for backup and restore intent — see Intent-gated privileges. |
self_sid | The caller. | The SID that PRINCIPAL_SELF (S-1-5-10) should resolve to during the DACL walk. Used by directory-style objects. |
local_claims | The caller. | Per-call attributes in the @Local.* namespace, available to conditional ACE expressions. |
| Object type list | The caller (optional). | A tree of property GUIDs for per-property evaluation. See "Object type list" below. |
| PIP type and trust | The PSB of the calling process. | Used by the PIP step to compare against any trust label on the object. |
| Object audit context | The caller. | An opaque blob included in any audit events emitted for this call, identifying the object to audit consumers. |
Outputs:
| Output | Meaning |
|---|---|
| Granted mask | The 32-bit access mask of rights the caller actually gets. |
| Audit emissions | Any audit events that fired (SACL audit ACEs, privilege-use, token-forced policy events). |
| Continuous audit mask | For file-like objects, the per-operation audit mask to cache on the open handle. See The SACL. |
| Staging mismatch flag | Set when a staged CAAP policy would have produced a different result. See Central access policies. |
The function does not return a boolean. It returns a granted mask, and the caller is responsible for comparing that mask against what was requested. "Access denied" is what happens when the granted mask does not contain all the requested rights — but partial grants are visible to the caller, and MAXIMUM_ALLOWED callers specifically want to know everything they would have gotten.
The pipeline, step by step #
The pipeline runs in a defined order. Each step either pre-decides bits, modifies the running state, or both. The kernel implements it as a 15-step procedure; this section names each step, says what it does, and points to where it is covered in detail.
| Step | Name | What it does |
|---|---|---|
| 0 | Impersonation level gate | If the effective token is Impersonation-type at the Identification level, deny everything immediately. The token may be inspected but not used for access decisions. |
| 1 | Input validation | The SD must be present, must have an owner, must have a primary group. Object type list must be well-formed. Malformed inputs are rejected with appropriate errors. |
| 2 | Generic mapping | Generic rights in the desired mask (GENERIC_READ, GENERIC_WRITE, etc.) are expanded to object-specific bits using the object's GenericMapping table. MAXIMUM_ALLOWED is stripped and remembered as a mode. |
| 3 | Effective privileges | Backup and restore privileges are stripped from the privilege set unless the caller passed the corresponding intent flag. See Intent-gated privileges. |
| 4 | Privilege grants | Pre-decide bits that are granted by privilege regardless of the DACL: ACCESS_SYSTEM_SECURITY via SeSecurity, backup-read bits via SeBackup, write/metadata bits via SeRestore. See Privileges in the pipeline. |
| 5 | Pre-SACL walk | Scan the SACL for the mandatory integrity label (MIC) and the PIP trust label. Pre-decide write/read/execute bits as denied for non-dominant callers. Resource attributes are extracted and indexed for conditional expressions. See Mandatory integrity control and Process integrity protection. |
| 6 | Virtual group injection | OWNER RIGHTS (S-1-3-4) and PRINCIPAL_SELF (S-1-5-10) are injected into the token's group view if the caller owns the object or matches self_sid respectively. See Ownership. |
| 7 | Tree initialisation | If the caller passed an object type list (per-property evaluation), the pipeline's decided/granted state is seeded per node. Skipped for ordinary calls. |
| 8 | Normal DACL evaluation | Grant owner implicit rights (READ_CONTROL, WRITE_DAC) unless suppressed by OWNER RIGHTS. Walk the DACL using first-writer-wins, applying ACE flags, propagating per-property results for object type lists. See DACL evaluation. |
| 9 | Post-DACL SeTakeOwnership | If the walk did not grant WRITE_OWNER and the mandatory policy did not block it, SeTakeOwnershipPrivilege grants it now. See Ownership. |
| 10 | Restricted token pass | If the token is restricted, the DACL is re-evaluated using only the restricted SIDs, and the result is intersected with step 8's result. Privilege-granted bits from step 4 are restored after the intersection. See Restricted and write-restricted tokens. |
| 11 | Confinement pass | If the token is confined, the DACL is re-evaluated against the confinement SID set and the result is absolutely intersected. Privileges do not bypass confinement. See Confinement. |
| 12 | CAAP evaluation | Each SYSTEM_SCOPED_POLICY_ID ACE in the object's SACL is resolved and evaluated. Each rule's DACL is intersected with the running grant. Staging mismatches are recorded. See Central access policies. |
| 13 | Privilege-use audit emission | For each privilege that contributed bits, an audit event is emitted if the token's audit_policy requested it (success or failure variant depending on whether the bits survived). |
| 14 | SACL audit walk | Walk the object's SACL and all collected CAAP effective SACLs for SYSTEM_AUDIT* and SYSTEM_ALARM* ACEs. Emit audit events; compute continuous-audit mask. See Auditing. |
| 14b | Token audit policy | The token's audit_policy field can force audit events independently of any SACL ACE. Success and failure flags emit policy-forced events. |
| 15 | Result computation | The final granted mask is granted & mapped_desired. The function returns this mask, plus the audit emissions, the continuous-audit mask, and the staging mismatch flag. |
The numbering is the kernel's; you will see it in audit and error messages.
Reading the pipeline #
A few observations about the shape that are worth pinning before reading the deeper pages:
Pre-DACL layers can pre-decide bits as denied. MIC and PIP do this for non-dominant callers. A bit pre-decided as denied stays denied; no later step can grant it. The owner of an object that has a high mandatory label cannot read it from a lower-integrity session even though the DACL would grant the read.
Pre-DACL layers can pre-decide bits as granted. Step 4 (privilege grants) does this. Bits granted by privilege are decided before the DACL walk runs; the walk's first-writer-wins applies to undecided bits, so a privilege grant is effectively immune to a later DACL deny.
The DACL walk is the central act. Steps 0–7 set up state for it; steps 9–12 narrow what it produced. The walk itself, governed by DACL evaluation, is where most of the visible policy lives.
Narrowing layers (restricted, confinement, CAAP) only remove access, never add. A right that the DACL plus privileges granted may be lost in steps 10–12. A right that those did not grant cannot be added by the narrowing layers.
Audit fires last and observes everything. By the time the audit walk runs (step 14), the granted mask is final. Audit ACEs can compare against the requested mask, the granted mask, and the difference, and decide whether to emit. Token-forced audit policy can emit even when no SACL ACE matched.
A staging mismatch is informational, not corrective. Step 12 evaluates both the effective CAAP rules (which affect the granted mask) and the staged CAAP rules (which do not). If they would have produced different results, the function reports it; it does not change the granted mask.
Two AccessCheck variants #
The kernel exposes two AccessCheck syscalls. They differ in what they return:
| Syscall | What it does |
|---|---|
kacs_access_check | The common case. Returns a single granted mask for the whole object. |
kacs_access_check_list | The per-property variant. Requires an object type list and returns a separate granted mask and status per node in the tree. Used by directory-style objects with property-level access control. |
The pipeline is the same for both. The list variant just tracks per-node state through every step, so an object type list with twelve nodes produces twelve granted masks at the end, one per node.
For most objects (files, registry keys, tokens), the regular variant is the right one. Per-property is meaningful only when the object has properties that can be granted independently — for example a directory object with named attributes.
Object type list, briefly #
Object ACEs in a DACL carry a property GUID. When the caller passes an object type list, AccessCheck evaluates each ACE against each node of the tree, propagating per-property grants and denials according to the tree structure (a grant on a parent set flows to its children; a denial flows up; sibling grants intersect).
The tree itself is provided by the caller as a preorder-flat array of kacs_object_type_entry records. The array must be well-formed: starts at level 0, no level gaps, no duplicate GUIDs, and so on. Malformed lists are rejected at step 1.
The full mechanics of object ACEs and the object type list are in ACLs, ACEs, and access masks. Most code never builds an object type list — they exist for the directory-object case specifically.
Where to start #
If you want MIC in depth — what an integrity level actually does to access, how the policy bits work, how the SD's mandatory label drives it — read Mandatory integrity control.
If you want to know exactly which bits each AccessCheck-influencing privilege grants and where in the pipeline that grant happens, read Privileges in the pipeline.
If you want the narrowing layers — restricted-token pass, confinement pass, CAAP — in detail, read Narrowing layers.
If you are debugging an unexpected denial right now, read Debugging a denial.
Mandatory integrity control
Peios / Peios Security Fundamentals / Access Decisions
Mandatory integrity control is the layer of the access check that gates rights based on a numeric trust axis called the integrity level. Where the DACL asks "is this principal allowed?", MIC asks "is the caller trusted enough to do this regardless of the DACL?". A non-dominant caller — one whose integrity is lower than the object's — has certain rights pre-decided as denied, before the DACL is even looked at.
The integrity axis is independent of identity. Two threads, same user, can run at different integrity levels and see different effective access. An interactive shell at Medium and an elevated session at High are the same person, separated by integrity. MIC enforces that separation.
MIC fires in step 5 of the access check pipeline — pre-DACL. Its decisions are immutable from that point. The DACL walk, the restricted-token pass, none of them can undo a MIC denial.
The integrity levels #
In practice there are five standard integrity levels, strictly ordered — treat integrity as an enum even though, technically, it is not one (see below). These five are the levels anyone normally works with (canonical value catalog: Other constants):
| Level | SID | RID | Typical use |
|---|---|---|---|
| Untrusted | S-1-16-0 | 0 | Tokens for highly-sandboxed processes. The lowest level. |
| Low | S-1-16-4096 | 4096 | Tokens for confined applications, anti-malware quarantines. |
| Medium | S-1-16-8192 | 8192 | The default for interactive sessions and most services. |
| High | S-1-16-12288 | 12288 | Elevated administrative sessions. The Full token in a UAC-style linked pair. |
| System | S-1-16-16384 | 16384 | The kernel, peinit, authd, and the rest of the TCB. |
The comparison is the obvious one: Untrusted < Low < Medium < High < System. A caller dominates an object if the caller's integrity level is at least the object's level. Otherwise the caller is non-dominant and MIC's policy bits decide what they cannot do.
Technically, though, the level is not an enum. An integrity level is simply the single sub-authority of an S-1-16 mandatory-label SID, read as an unsigned integer, and levels are compared numerically. Any S-1-16-<n> SID with exactly one sub-authority is a valid level; only a SID with the wrong identifier authority (not S-1-16) or more than one sub-authority is malformed and rejected. The kernel does not check a label SID against a hardcoded list — an unusual value is interpreted purely by its numeric position, and the five standard RIDs are spaced by 4096 to leave room between them. This mostly matters for Windows interop, where non-standard levels appear: medium-plus (S-1-16-8448) sits just above Medium, and protected (S-1-16-20480) sits above System. Treat the five named levels as the working model; just don't assume they are the only values a label can carry.
Where the object's label lives #
An object's mandatory integrity label is in its SACL, as a SYSTEM_MANDATORY_LABEL_ACE. The ACE has:
- A SID from the integrity namespace (an
S-1-16SID with one sub-authority — normally one of the five above, occasionally a non-standard level such asmedium-plusorprotected). - A mask containing the MIC policy bits.
When the access check looks at an SD, it scans the SACL for SYSTEM_MANDATORY_LABEL_ACE entries that are not inherit-only. The first such ACE is the object's effective label. If there is none, the object's effective label is Medium with NO_WRITE_UP — that is the default for an unlabelled object.
The Medium-with-NO_WRITE_UP default exists to protect default objects from lower-integrity callers without requiring every object to carry an explicit label. An unlabelled file on a standard filesystem is automatically protected against writes from a Low-integrity sandboxed process; the sandbox cannot scribble on system data even though no one explicitly marked the data with a label.
The SACL ACE also lives there for a reason: changing the integrity label is a privileged operation (gated by SeRelabelPrivilege for label-raising and by the standard SACL access rule for label-lowering). Stored as a SACL ACE, the label is automatically under SACL access control — the owner of an object cannot lower its integrity label without SeSecurityPrivilege or SeRestorePrivilege. This is exactly the right gate; a user should not be able to weaken the protections on their object just because they own it.
The MIC policy bits #
The mask of a SYSTEM_MANDATORY_LABEL_ACE contains MIC policy bits that say what non-dominant callers cannot do (canonical values: ACE types and flags):
| Bit | Value | Effect on non-dominant callers |
|---|---|---|
SYSTEM_MANDATORY_LABEL_NO_READ_UP | 0x01 | Read-category rights are denied. |
SYSTEM_MANDATORY_LABEL_NO_WRITE_UP | 0x02 | Write-category rights are denied. |
SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP | 0x04 | Execute-category rights are denied. |
The names use "up" because the non-dominant caller is below the object. A "no write up" rule denies a Medium caller from writing to a High object — the caller would be writing "up" to higher integrity.
The bits compose. A label with all three set blocks all access categories from non-dominant callers. A label with only NO_WRITE_UP allows non-dominant callers to read but not write. The most common combination by far is NO_WRITE_UP alone — it is the default, and it captures the most important integrity rule (lower-integrity code should not be able to modify higher-integrity data).
Read-up and execute-up restrictions are less common. They appear on objects whose existence or contents should be hidden from lower-integrity callers entirely.
How MIC fires in the access check #
flowchart LR
A["Token integrity level"] --> M["MIC compare"]
B["Object integrity label (or Medium/NWU default)"] --> M
M -->|caller dominates: pass| C["Continue to DACL walk"]
M -->|caller does not dominate: apply policy bits| D["Pre-decide affected bits as denied"]
D --> C
At step 5 of the pipeline:
- Extract the object's integrity label (or apply the Medium-with-NO_WRITE_UP default if there is no label ACE).
- Compare the token's
integrity_levelagainst the object's label. - If the token's level is at least the object's level, MIC is satisfied. Nothing happens; the pipeline proceeds.
- If the token's level is below the object's, look at the label's policy bits. For each bit set, take the corresponding category of rights from the desired mask and mark those bits as decided-and-denied. The bits enter the pipeline's
decidedset but notgranted.
The categories — read, write, execute — are defined by the object's GenericMapping. For a file, the read category is the set of bits that map from GENERIC_READ (FILE_READ_DATA, FILE_READ_ATTRIBUTES, FILE_READ_EA, READ_CONTROL, SYNCHRONIZE). Same idea for the write and execute categories.
By the time the pipeline reaches the DACL walk in step 8, the bits MIC denied are already decided. The walk processes the remaining bits; the denied ones are skipped.
The "token mandatory policy" field #
A separate but related field: every token carries a mandatory_policy bitmask. It uses two flags:
| Flag | Value | Effect |
|---|---|---|
NO_WRITE_UP | 0x01 | The MIC rule is active for this token's accesses. |
NEW_PROCESS_MIN | 0x02 | At exec, if the executable's integrity label is lower than the token's, the token's integrity is lowered to match. |
Both flags are set at token creation and cannot be changed at runtime. A process cannot relax its own MIC policy — the kernel rejects any AdjustPrivileges-style call that would. The reasoning: a Medium-integrity process should not be able to silently turn off its own write-up restriction.
NO_WRITE_UP is set on essentially every token authd issues. It is the active state of MIC enforcement, and it is worth understanding that it is load-bearing rather than a formality: a token without it carries an integrity level that nothing consults. The access check returns before comparing labels at all, so such a token holds a level that is pure decoration — it neither restricts the token nor protects anything from it.
Where a token's level comes from #
The object's label comes from its SACL, as above. The token's level is decided once, when the token is minted, and never changes afterwards.
For a logon token that is local policy — a per-principal record in the registry that authd reads at every sign-in, keyed on the SIDs the token ends up carrying. A principal source has no way to influence it, deliberately: how much a machine trusts someone is not a fact about them that a directory could know. A principal no record names gets Medium. See assigning privileges for the record format.
For service tokens, peinit decides. For the SYSTEM token, the kernel does.
NEW_PROCESS_MIN is set on tokens that want exec to enforce integrity boundaries. When set, exec of a binary marked at a lower level produces a token whose integrity is lowered. This is what prevents a High-integrity shell from launching a Medium-labelled binary as High-integrity — the resulting process is Medium, regardless of who launched it.
What MIC does not constrain #
A surprisingly important set of things.
MIC does not constrain privilege grants. Step 4 of the pipeline (privilege grants) runs before MIC, and the bits it pre-decides as granted survive MIC. A caller holding SeBackup with BACKUP_INTENT set gets read-category rights via the privilege regardless of MIC. The reason: privileges are explicit, intentional, and audited — they are not subject to the same "block by default" treatment as the DACL.
The exception is PIP. Where MIC ignores privilege grants, PIP strips them. A non-dominant PIP caller loses privilege-granted rights, not just DACL-granted ones. PIP is the stricter integrity-style enforcement; MIC is the looser one. See Process integrity protection.
MIC does deny WRITE_OWNER sought via SeTakeOwnership. Unlike the step-4 privileges, SeTakeOwnership fires post-DACL (step 9) and only on bits not already mandatorily decided — and MIC pre-decides WRITE_OWNER as denied for every non-dominant caller. A tool that needs ownership of higher-integrity objects must hold SeRelabelPrivilege (the one privilege that punches WRITE_OWNER through MIC) or use SeRestorePrivilege with RESTORE_INTENT, whose step-4 grant precedes MIC.
MIC does not lower integrity automatically on access denial. A non-dominant caller blocked by MIC just gets the relevant bits denied. The token's integrity is not adjusted. The same token can succeed at accessing a lower-integrity object on the next call.
MIC is asymmetric. A non-dominant caller is constrained; a dominant caller is not. There is no "no write down" — a High-integrity process writing to a Medium object faces no MIC restriction. This sometimes surprises people. The model is "lower integrity cannot touch higher", not "integrity levels cannot interact at all".
Integrity and impersonation #
The two-gate model in Impersonation refuses to let a server act as a client of higher integrity. A Medium-integrity service that captures a High-integrity client's peer token gets an Identification-level impersonation token — it can inspect the identity, but no access check will pass.
This is enforced unconditionally. SeImpersonatePrivilege does not bypass it. A High-integrity client connecting to a Medium service should not be able to make the Medium service operate at High — that would void the MIC model for the service.
The implication for MIC enforcement: a non-dominant caller cannot escape MIC by impersonating someone with higher integrity. The integrity ceiling downgrades such a token to Identification, so it can never be used to act at a higher integrity than the server's own.
Setting an integrity label #
The label on an object is changed via kacs_set_sd with LABEL_SECURITY_INFORMATION (or SACL_SECURITY_INFORMATION for the whole SACL). The rules:
- Lowering a label (to a level at or below the caller's own integrity) is permitted. No special privilege required beyond the normal SACL access rule (
SeSecurityPrivilegeorSeRestorePrivilege). - Raising a label (to a level above the caller's own integrity) requires
SeRelabelPrivilege. The privilege is rare; only the TCB and specific labelling services hold it.
The asymmetry is the obvious one: you can always demote an object to your own level or lower, but you cannot promote an object beyond what you yourself can reach. This prevents a Medium administrator from labelling an object High and then locking themselves out from below — they cannot label objects at a level they could not access.
For LABEL_SECURITY_INFORMATION specifically, the call is mutually exclusive with SACL_SECURITY_INFORMATION in the same kacs_set_sd invocation. You either update the integrity label only, or you update the whole SACL.
MIC vs PIP in one paragraph #
MIC and PIP look superficially similar. Both compare a level on the caller against a level on the object. Both block non-dominant access. The differences are crucial:
- MIC uses the effective token's
integrity_level. PIP uses the PSB'spip_typeandpip_trust. Impersonation changes the effective token but not the PSB; MIC respects impersonation, PIP does not. - MIC is a one-axis comparison. PIP is two axes (type and trust), both of which must dominate.
- MIC ignores privilege grants. PIP strips them. A privilege can rescue access from MIC; it cannot rescue access from PIP.
- MIC has a default (Medium/NO_WRITE_UP). PIP has no default. Objects without a
SYSTEM_PROCESS_TRUST_LABEL_ACEare not PIP-protected; objects without aSYSTEM_MANDATORY_LABEL_ACEget the default MIC treatment.
The two layers complement each other. MIC is the "user identity is at this trust level" axis; PIP is the "the binary running this process has this trust level" axis. An access can be blocked by either, allowed by both, or — in the rare cases where a privilege bridges MIC but not PIP — allowed by MIC and blocked by PIP.
Where to go next #
For the privileges that can carry an access past MIC — and where each fires in the pipeline — read Privileges in the pipeline.
For the binary-trust axis MIC is so often compared with, read Process integrity protection.
Privileges in the pipeline
Peios / Peios Security Fundamentals / Access Decisions
Five privileges in the catalog change what AccessCheck decides. Each one fires at a specific step of the pipeline. Each one grants a specific set of bits. Each one is bypassed by some layers and not by others. Understanding which is which is what lets you reason about access decisions that involve privileges — when a privilege rescued an access, when it could have but did not, when it would have been irrelevant.
This page covers each of the five AccessCheck-influencing privileges in pipeline order: where it fires, what it grants, what bypasses it, and how it shows up in audit.
The intent model itself — why BACKUP_INTENT and RESTORE_INTENT exist as separate gates — is on Intent-gated privileges. This page assumes that material.
The five privileges, at a glance #
| Privilege | Fires at step | Grants | Intent-gated? |
|---|---|---|---|
SeSecurityPrivilege | 4 | ACCESS_SYSTEM_SECURITY | No |
SeBackupPrivilege | 4 | Read-category rights | Yes (BACKUP_INTENT) |
SeRestorePrivilege | 4 | Write-category rights, WRITE_OWNER, WRITE_DAC, DELETE, ACCESS_SYSTEM_SECURITY | Yes (RESTORE_INTENT) |
SeTakeOwnershipPrivilege | 9 | WRITE_OWNER | No |
SeRelabelPrivilege | (not AccessCheck — kacs_set_sd) | Setting an integrity label above the caller's own | No |
Steps 4 and 9 are both privilege-grant steps in the access check pipeline (see Access decisions overview). The difference is when in the pipeline they run. Step 4 is pre-DACL — privileges that fire there decide bits before the DACL walk happens. Step 9 is post-DACL — SeTakeOwnershipPrivilege fires only if the DACL did not already grant WRITE_OWNER.
SeRelabelPrivilege does not fire inside AccessCheck at all. It is consulted by kacs_set_sd when the caller is trying to change an object's integrity label above their own. It is listed here because, like the others, it modifies what would otherwise be a denial decision.
SeSecurityPrivilege — SACL access #
ACCESS_SYSTEM_SECURITY (bit 0x01000000) gates reading and writing the SACL. The DACL never grants this right — there is no ACE you can write to allow non-privileged users SACL access. The only grant of ACCESS_SYSTEM_SECURITY comes from a privilege.
SeSecurityPrivilege, when enabled on the calling token, grants ACCESS_SYSTEM_SECURITY at step 4 of the pipeline if it appears in the desired mask. The grant is unconditional in the sense that no DACL or MIC bit can deny it. PIP can: a non-dominant PIP caller will lose this bit along with the other privilege-granted bits.
In addition to its access-check role, SeSecurityPrivilege is also a kernel-standalone privilege that gates several Linux capability checks (CAP_AUDIT_CONTROL, CAP_MAC_ADMIN, CAP_AUDIT_READ). The same privilege wears two hats.
SeRestorePrivilege also grants ACCESS_SYSTEM_SECURITY in its specific use cases (restoring an SD via kacs_set_sd). The two privileges are not equivalent: a holder of SeSecurity can read or write any SACL freely; a holder of SeRestore can do it as part of a restore operation but the rest of the access-check pipeline treats them differently.
In audit, an ACCESS_SYSTEM_SECURITY grant attributable to SeSecurityPrivilege is recorded as such. A consumer parsing audit events can distinguish "SACL was modified because the DACL granted it" (cannot happen) from "SACL was modified because the caller held the privilege" (always the cause).
SeBackupPrivilege — read-category, intent-gated #
SeBackupPrivilege is the read-bypass privilege. With BACKUP_INTENT set in privilege_intent and the privilege enabled on the token, AccessCheck at step 4 grants the read-category bits of the desired mask regardless of the DACL.
The "read category" is determined by the object's GenericMapping. For a file: FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL. For a registry key: the corresponding read rights on a key. For a token: TOKEN_QUERY | READ_CONTROL.
The grant is pre-decided in step 4. By the time the DACL walk runs in step 8, the read-category bits are already decided as granted, and the walk only processes the remaining (write, execute, ownership) bits. If the DACL would have granted some read bits anyway, the privilege grant is redundant; if the DACL would have granted none, the privilege grant is the source.
The intent flag is what activates the privilege. Without BACKUP_INTENT, step 4's SeBackup processing is skipped; the privilege has no effect on this call. The intent must come from the caller — there is no kernel default. See Intent-gated privileges.
SeRestorePrivilege — write-category, intent-gated #
SeRestorePrivilege is the write-bypass privilege. With RESTORE_INTENT set and the privilege enabled, AccessCheck at step 4 grants the write-category bits plus several adjacent rights:
- Write-category bits (
FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EAfor files; corresponding bits for other types). DELETE.WRITE_OWNERandWRITE_DAC.ACCESS_SYSTEM_SECURITY(so a restore can rewrite the SACL).
That last bit is the reason a restore tool does not also need to hold SeSecurityPrivilege — SeRestore folds the SACL-write authority into the restore-flavoured grant.
Beyond the access-check grant, SeRestore has one more effect: it bypasses the "new owner must be self or SE_GROUP_OWNER group" restriction in kacs_set_sd. A holder of SeRestore can set an object's owner to any well-formed SID, not just their own. This is what lets a restore reconstitute an object's original owner even when the original principal is not present on the running system.
Like SeBackup, the privilege is intent-gated. Without RESTORE_INTENT, it is skipped at step 4 and has no effect.
SeTakeOwnershipPrivilege — WRITE_OWNER, post-DACL #
SeTakeOwnershipPrivilege is special because of when it fires: step 9, after the DACL walk. The reasoning: the DACL might already grant WRITE_OWNER to the caller, in which case the privilege is unnecessary. The pipeline runs the walk first, then checks: did the walk grant WRITE_OWNER? If yes, nothing to do. If no, and the caller holds SeTakeOwnership, grant it now.
This timing means SeTakeOwnership produces a different audit signature than the step-4 privileges. The "this right was granted by SeTakeOwnership" event fires only when the DACL would not have granted WRITE_OWNER anyway. If you see SeTakeOwnership in an audit log, you know the caller actually needed the privilege for that access; if the DACL had been sufficient, the privilege would not have shown up.
SeTakeOwnership does not bypass MIC. A Medium-integrity caller cannot take ownership of a High-integrity object even with the privilege, because MIC will have pre-decided WRITE_OWNER as denied at step 5 (before SeTakeOwnership runs at step 9). The privilege only fires on bits not already decided.
It also does not bypass PIP — same reasoning, PIP fires before step 9 and can pre-decide WRITE_OWNER as denied for non-dominant PIP callers.
The "new owner must be self or SE_GROUP_OWNER group" rule applies to taking ownership via this privilege. The privilege grants the right to write the owner field, not freedom from validation. To set the owner to an arbitrary SID, the caller additionally needs SeRestore.
SeRelabelPrivilege — raising the integrity label #
SeRelabelPrivilege is the integrity-label privilege. It does two things:
- During
kacs_set_sdwithLABEL_SECURITY_INFORMATION(orSACL_SECURITY_INFORMATION), it allows the caller to set the integrity label to a level above their own. Without the privilege, an object's label can be lowered (to at or below the caller's level) but not raised. - It bypasses the standard MIC check on
WRITE_OWNERwhen the caller is operating on an object with a higher integrity label — specifically, the privilege "punchesWRITE_OWNERthrough MIC" for non-dominant callers, so a relabelling tool can adjust ownership as part of a relabel even if the object is currently at a higher integrity than the tool.
SeRelabelPrivilege is one of the rarest privileges. It is held only by tools that legitimately need to set system-level integrity labels — typically TCB labelling utilities. Almost no general-purpose code holds it.
What privileges do not bypass #
A handful of layers ignore the privilege-grant step entirely.
Confinement. Confinement pass (step 11) intersects the running grant with what the confinement SID set would grant. Privilege-granted bits are not preserved through this intersection. A confined token with SeBackup enabled can still be confined out of read access; the privilege is gone.
This is the major difference between confinement and the restricted-token pass. Restricted-token intersection (step 10) preserves privilege-granted bits — they are restored after the intersection. Confinement does not. The reason: confinement is policy applied to the code from outside, and a confined application should not be able to escape its sandbox by exercising a privilege. See Confinement.
CAAP. Central access policies, evaluated at step 12, can narrow what privileges have already granted. A CAAP rule whose DACL does not grant the right will strip it from the running grant. Privilege-granted bits are not immune.
PIP. Non-dominant PIP callers have their privilege-granted bits stripped in step 5 (PIP processing). This is unique to PIP — MIC is content to leave privilege-granted bits alone, but PIP actively revokes them. The reason is the same as the confinement reason: PIP is about isolating untrusted binaries from trusted ones, and a privilege that bypassed PIP would defeat the model.
A useful mnemonic: privileges survive restricted-token narrowing; they do not survive confinement, CAAP, or PIP. The first is the privilege-friendly narrowing layer; the others are privilege-blind.
Privilege-use audit #
Step 13 of the pipeline is the privilege-use audit emission. For each privilege that contributed bits in steps 4 or 9, the kernel checks the token's audit_policy and emits an audit event accordingly.
The events fall into two flavours:
- Successful privilege use. The privilege contributed bits and those bits survived to the final granted mask. Emitted when
PRIVILEGE_USE_SUCCESS(0x04) is set inaudit_policy. - Failed privilege use. The privilege contributed bits but they did not survive — stripped by some later layer (confinement, CAAP, PIP). Emitted when
PRIVILEGE_USE_FAILURE(0x08) is set.
The failed-use case is what tells you "a privilege was exercised but was unable to actually grant access". This is auditable separately from a plain "access denied" — the privilege got far enough to fire and then got stripped.
The events themselves are documented in Auditing.
Reading a granted mask back #
A practical concern: when an access check returns a granted mask that includes a right, how do you know whether the right came from the DACL or from a privilege?
You cannot tell from the granted mask alone. The bit is the same regardless of source. The way to tell is the audit log: privilege-use audit events identify which privilege contributed which bits. If you see in the audit log that SeBackup granted FILE_READ_DATA on a specific access, you know the DACL did not.
In code, the way to verify is to issue the access check twice: once with the privilege intent flag (or with the privilege enabled), and once without. The difference in granted mask is what the privilege contributed.
For most code paths, this introspection is unnecessary — the granted mask is whatever it is, and the caller acts on it. But for auditing, debugging, or testing pipelines that exercise privileged paths, the distinction matters.
What goes in audit #
The privilege-use audit event for each fire records:
- The name of the privilege (e.g.
SeBackupPrivilege). - The bits the privilege contributed pre-narrowing.
- The bits that survived to the final granted mask.
- Whether the use was successful (bits survived) or failed (bits were stripped).
This is enough for an audit consumer to reconstruct what happened. A success event with non-empty surviving bits shows what the privilege ended up granting; a failure event with empty surviving bits shows what the privilege tried to grant and lost. Together they tell the story of where the access landed.
Where to go next #
For the three layers that can strip privilege-granted bits after the fact, read Narrowing layers.
For the privilege-use events themselves and where they end up, read Auditing.
Narrowing layers
Peios / Peios Security Fundamentals / Access Decisions
The access check has three layers that narrow the running granted mask: the restricted-token pass at step 10, the confinement pass at step 11, and the central access policy evaluation at step 12. Each runs after the DACL walk has produced an initial grant. Each is a strict intersection — they can only remove bits from the grant, never add. A right granted by some earlier layer (the DACL or a privilege) may be lost in these passes. A right not granted by an earlier layer cannot be created by them.
This page covers the three layers in pipeline order, the rules for what does and does not bypass each one, and how they compose when more than one is active.
The order, and why it matters #
| Step | Layer | What it intersects against |
|---|---|---|
| 10 | Restricted-token pass | A DACL walk using only the token's restricted_sids |
| 11 | Confinement pass | A DACL walk using only the token's confinement SID set |
| 12 | CAAP evaluation | The effective DACLs of each referenced central access policy |
The order is fixed. The restricted pass runs first because it is identity-flavoured — the kernel is asking "would the restricted SIDs alone have been granted this?". Confinement runs next because it is policy applied to the code as a whole, and the inputs to the confinement check are bigger than just the SIDs. CAAP runs last because it can layer multiple policies on top of everything.
A key consequence of the order: a right that survives all three is granted. A right denied by any one is denied. Composition is conjunction — every active narrowing layer must permit the right for it to remain in the final granted mask.
Restricted-token pass #
If the token is not restricted (its restricted_sids list is empty), this step is skipped entirely. If it is, the kernel runs the DACL walk a second time, using only restricted_sids as the matching set. The user_sid does not participate. The groups list does not participate. Only entries in restricted_sids count.
The result is granted_restricted — what the DACL would grant if the token had only its restricted SIDs. This mask is intersected with the running grant from step 8: granted = granted & granted_restricted.
Privileges bypass this pass. After the intersection, bits that were granted by privileges in steps 4 or 9 are restored to the running grant. The reason is that privileges are explicit and authorised independently of identity-based narrowing — a restricted token holder with SeBackup enabled and BACKUP_INTENT set can still exercise the privilege.
The write-restricted variant narrows only write-category bits. Reads and execute come from the step-8 grant alone. The bits in the write category are determined by the object's GenericMapping — for files, that is whatever GENERIC_WRITE maps to. See Restricted and write-restricted tokens for the full mechanics, including the user_deny_only flag that write-restricted tokens carry.
The restricted-token pass is the program's narrowing layer. It is set up in code: a sandbox launcher FilterTokens its own token down to a restricted version before launching the constrained work. The code is choosing to give itself less authority. Privileges, being explicit, are not subject to this self-imposed narrowing.
Confinement pass #
If confinement_sid is null on the token, this step is skipped. If it is set, and confinement_exempt is false, the kernel runs the DACL walk a third time, against the confinement identity:
- The user SID is replaced with
confinement_sid. - The groups list is replaced with the
confinement_capabilitieslist. - Group attributes are ignored — presence-based matching only.
- Owner implicit rights are not applied (the confined application is not the owner).
The result is granted_confinement — what the DACL would grant to a "fresh" caller whose entire identity is the confinement SID plus the declared capabilities. This is intersected with whatever the running grant is at this point.
Privileges do not bypass this pass. Bits granted by privileges in steps 4 or 9 are subject to the confinement intersection like any other bit. A confined token with SeBackup enabled and BACKUP_INTENT set can have its privilege grant stripped by confinement.
This is the major difference from the restricted-token pass. Restricted tokens trust their own author to use privileges responsibly; confinement does not trust the confined code at all. Confinement is policy applied to the application, not by it, so the application cannot escape via privilege exercise. See Confinement.
The confinement_exempt flag opts a specific token out of the confinement check. It is set very rarely — only by tools that legitimately need to step outside their own confinement.
CAAP evaluation #
Central access policies are the third narrowing layer. The mechanism: each SYSTEM_SCOPED_POLICY_ID ACE in the object's SACL references a policy by SID. The kernel looks up each policy in its policy cache (populated by authd) and evaluates the policy's rules.
A policy contains zero or more rules, each with:
- An optional applies-to expression — a conditional expression evaluated against the object's resource attributes. If the expression evaluates UNKNOWN or FALSE, the rule is skipped for this object. If it evaluates TRUE (or is absent), the rule applies.
- An effective DACL — a DACL that gets evaluated through a recursive sub-AccessCheck against the calling token. The result is intersected with the running grant.
- An optional effective SACL — audit ACEs that are collected for the eventual audit walk.
- Optional staged DACL / staged SACL — same content, but evaluated in parallel for testing purposes without affecting the actual grant.
For each applicable rule, AccessCheck recursively evaluates the effective DACL using a synthetic SD (where the DACL is the policy's DACL, the SACL has SYSTEM_SCOPED_POLICY_ID ACEs stripped to prevent recursion, and the object's owner/group are preserved). The result is intersected with the running grant.
Privileges do not bypass CAAP — bits granted by privileges are subject to CAAP narrowing like every other bit. The reason: CAAP is administrator-imposed policy, layered on top of object-level DACLs, and a privilege that bypassed it would defeat the model.
Staging mismatches are surfaced separately. If the staged DACL would have produced a different result, the access check sets the staging_mismatch flag in its output. The actual granted mask uses the effective DACL only — the staged DACL never affects what the caller gets. The flag is informational, for policy testing and rollout.
The recovery policy is the kernel's fallback when a referenced policy SID is not in the cache. Rather than fail-open (grant everything as if the policy were absent) or fail-closed (deny everything), the kernel applies a hardcoded recovery policy that grants GENERIC_ALL to BUILTIN\Administrators, SYSTEM, and OWNER_RIGHTS. This way, administrative access survives a missing-policy condition; ordinary access does not.
The full mechanism — applies-to expressions, staged vs effective semantics, distribution from authd — lives in Central access policies. For this page, what matters is that CAAP is the last narrowing layer, that it is privilege-blind, and that it produces the staging-mismatch signal.
Composition #
When more than one narrowing layer is active, they compose in pipeline order:
flowchart LR
A["Step 8 grant (DACL + step-4 privileges)"] -->|step 9| B["+ SeTakeOwnership if needed"]
B -->|step 10| C["intersect with restricted pass (if restricted)"]
C -->|step 4/9 privileges restored| D["intermediate grant"]
D -->|step 11| E["intersect with confinement pass (if confined)"]
E -->|step 12| F["intersect with each CAAP rule"]
F --> G["final granted mask"]
A token can be all three at once: restricted, confined, and accessing an object with CAAP references. Each layer fires; the running grant is intersected three times; the final grant is what survives all of them. Each is conservative — they can only remove. A right that survives is one that all three layers permitted, on top of whatever the DACL and privileges produced.
A token can be one and not the others. A non-restricted, non-confined token accessing a CAAP-bound object faces only step 12. A restricted but not confined token accessing a non-CAAP object faces only step 10. The skipped steps are no-ops.
Practical examples #
Restricted token, no confinement, no CAAP. A sandbox launcher created a restricted version of its own token and ran the workload as that. The workload's accesses go through the DACL walk normally, then through the restricted pass (intersection against restricted_sids), then through confinement (no-op, token is not confined), then CAAP (no-op, object has no policy). Privilege-granted bits from steps 4/9 are restored after the restricted pass — the workload can still exercise its privileges.
Confined application, no restricted SIDs, no CAAP. A service was launched with confinement policy attached by the service manager. The accesses go through the DACL walk normally, then skip the restricted pass (no restricted_sids), then through confinement (intersection against the confinement identity). Privilege-granted bits are subject to the intersection — if the confinement DACL would not have granted them, they are lost.
Plain DACL'd object with CAAP, ordinary token. A file with a SYSTEM_SCOPED_POLICY_ID ACE in its SACL. The DACL walk produces an initial grant. Restricted and confinement passes are no-ops. CAAP looks up the referenced policy, evaluates each applicable rule, intersects each rule's effective DACL with the running grant. The final grant is the DACL-walk result narrowed by every applicable CAAP rule.
All three. A restricted, confined token accessing a CAAP-bound object. Every narrowing layer is active. The final grant has to survive all of them. This is rare in practice — restricted and confined are both belts-and-braces approaches — but it is what the model permits.
What the narrowing layers do not do #
A few clarifications that come up:
- They do not run on objects that have no SACL. Restricted tokens and confinement narrowing depend on the token, not on the SACL. The DACL walk happens; the restricted/confinement pass runs based on token state. Only CAAP requires SACL ACEs to fire.
- They do not change the DACL itself. The narrowing layers compute a result, not a rewritten DACL. The same DACL on the same object produces different results for a normal token vs a restricted one vs a confined one — and the DACL itself is unchanged.
- They do not affect audit. Audit ACEs in the SACL fire based on the final granted mask, not on what was granted by the DACL walk alone. An audit ACE that fires on success will fire only if the right survives all narrowing layers and ends up granted. A failure ACE fires only if the right would have been granted by some earlier layer but was stripped by a later one.
- They do not affect outputs other than the granted mask. Restricted-token and confinement passes do not produce their own audit events. CAAP produces the staging-mismatch flag and contributes effective SACL ACEs to the audit walk; it does not produce a separate "CAAP narrowed this" event.
Why three different layers? #
A reasonable question: if all three are intersections, why not just one? The reason is that they are intersections against different things, set by different audiences, with different bypass rules.
| Layer | Set by | Intersects against | Privileges bypass? |
|---|---|---|---|
| Restricted token | The application (in code) | The application's own restricted SID list | Yes |
| Confinement | The system administrator (policy) | The confinement SID + declared capabilities | No |
| CAAP | The directory administrator (policy) | Centrally-defined access rules | No |
The three sources of narrowing — the application, the local administrator, the directory — produce different layers because they answer different questions. "Does this code restrict its own authority?" is a programming concern; "is this service running in a sandbox?" is a deployment concern; "is this resource under enterprise data-protection policy?" is an organisational concern. Each layer handles its question.
The privilege-bypass split is the most consequential difference. Restricted tokens are application-internal and the application is trusted to use privileges responsibly. Confinement and CAAP are external policy and the bypassed code is not trusted; privileges must not be an escape route.
Where to go next #
For the systematic walk when an access comes back denied and you need to know which layer did it, read Debugging a denial.
For the token-side mechanics of the restricted pass, read Restricted and write-restricted tokens.
Debugging a denial
Peios / Peios Security Fundamentals / Access Decisions
A call returned ACCESS_DENIED, or a file open failed with EACCES, or a registry read came back empty. The access check decided "no", and you need to know which layer of the pipeline produced the denial. What follows is the systematic walk.
The pipeline has many steps, but the practical answer almost always falls in one of six categories. This page covers them in the order to check, with the inspection mechanics for each.
The six places a denial can come from #
Before stepping through, the list of candidates:
- The token cannot be used. The thread is impersonating at the Identification level (step 0).
- MIC denied it. The caller's integrity level is below the object's mandatory label, and the policy bits cover the requested category (step 5).
- PIP denied it. The caller's process trust label does not dominate the object's, and the requested right is outside the explicitly-allowed mask (step 5).
- The DACL did not grant it. The walk produced an empty result for the requested bits (step 8).
- A narrowing layer stripped it. Restricted-token pass, confinement, or CAAP removed bits the DACL had granted (steps 10–12).
- A privilege was needed and not present, or present and not enabled, or present and missing its intent flag. Steps 4 and 9 did not pre-decide the bits as granted.
For each of these, this page covers what to look at and what the finding means.
Find the thread and its tokens #
Almost every investigation starts with finding the thread that made the call and inspecting its tokens. The mechanics:
- The thread's primary token:
cat /proc/<pid>/task/<tid>/tokenproduces a query-only handle (or usekacs_open_thread_tokenprogrammatically). The handle can be queried withKACS_IOC_QUERYfor fields includinguser_sid,groups,integrity_level,privileges,restricted_sids,confinement_sid,auth_id, andimpersonation_level. - The thread's effective token (impersonation if installed, primary otherwise):
cat /sys/kernel/security/kacs/selfwhen run from the thread itself; for another thread, the same/proc/<pid>/task/<tid>/tokenpath returns the effective token.
For a non-self thread, you need PROCESS_QUERY_INFORMATION on the process and PIP dominance. For /proc/self/token and /sys/kernel/security/kacs/self, the kernel always permits inspection.
With the effective token in hand, several denials are immediately diagnosable:
impersonation_level == Identification— the thread is at the wrong impersonation level. AccessCheck on this token denies everything at step 0. Either the client did not request a higher level on the socket, or the two-gate model silently downgraded the impersonation. See The two-gate model.integrity_levelis low and the object is high — possible MIC denial; check the object's label next.restricted_sidsis non-empty — the token is restricted. The denial may be from the restricted-token pass; check what SIDs are in the restricted list.confinement_sidis set andconfinement_exemptis false — the token is confined. The denial may be from the confinement pass.- A privilege you expected to be enabled is in the
presentset but not theenabledset — the privilege is not active; AccessCheck cannot use it. - A privilege you expected to be on the token is absent entirely — authd did not include it; this is a privilege-policy question, not an access-check question.
Find the object and its SD #
Read the security descriptor of the object:
- For files:
kacs_get_sdon the path. Returns the SD as a self-relative binary blob. - For registry keys: the equivalent registry API on the key.
- For tokens: query the token handle with the appropriate KACS_IOC_QUERY class.
- For processes: query the PSB.
Parse the SD into its four components (owner, group, DACL, SACL).
The most useful things to read:
- The owner SID. Does the caller match? If yes, owner implicit rights apply (unless suppressed — see next).
- OWNER RIGHTS suppression. Is there an ACE on
S-1-3-4(OWNER RIGHTS) in the DACL? If so, the owner's implicitREAD_CONTROL | WRITE_DACis suppressed; the owner gets only what that ACE grants. - The DACL. What ACEs are present, in what order? Are there any
INHERIT_ONLYACEs that look like they should grant access but are actually skipped during the walk? - The mandatory integrity label. Look for a
SYSTEM_MANDATORY_LABEL_ACEin the SACL. The SID indicates the object's integrity level; the mask indicates the policy bits. - The PIP trust label. A
SYSTEM_PROCESS_TRUST_LABEL_ACEin the SACL marks the object as PIP-protected. - CAAP references.
SYSTEM_SCOPED_POLICY_ID_ACEentries point to central access policies that contribute to the decision. - Resource attributes.
SYSTEM_RESOURCE_ATTRIBUTE_ACEentries. Relevant if any of the DACL's conditional ACEs references@Resource.*.
Walk the pipeline #
With token and SD in hand, walk the pipeline. The questions, in order:
Impersonation level (pipeline step 0) #
Is the effective token an impersonation token at the Identification level? If yes, that is the denial. AccessCheck stops at step 0; no further evaluation occurs. The fix is at the client end: either the client requested Identification (and should have requested Impersonation), or the two-gate model downgraded a higher request to Identification (consult The two-gate model).
MIC (pipeline step 5) #
Is the object's effective mandatory label at a higher level than the token's integrity_level?
If yes, look at the label's policy bits. For the bits set, the corresponding rights are pre-decided as denied:
NO_READ_UP→ read-category bits are denied.NO_WRITE_UP→ write-category bits are denied (the default for unlabelled objects).NO_EXECUTE_UP→ execute-category bits are denied.
If the requested right falls in a denied category, MIC is the denial. The fix is to either raise the token's integrity (typically by re-authenticating with elevated rights via UAC-style flow) or lower the object's label (requires SeRelabelPrivilege for raising, normal SACL access for lowering). Note that a privilege grant from step 4 (SeBackup, SeRestore) survives MIC — if MIC blocked write but the caller had SeRestorePrivilege with RESTORE_INTENT, the write may still succeed.
PIP (pipeline step 5) #
Does the object have a SYSTEM_PROCESS_TRUST_LABEL_ACE? If so, look at its SID's type and trust levels and compare to the calling process's PSB.
For dominance: both pip_type and pip_trust on the caller must be at least the ACE's values. If not, only the rights in the ACE's mask are permitted, and privilege-granted bits are revoked (this is the difference between PIP and MIC).
If PIP denied a right, the fix is to run the caller as a more-trusted binary (PIP is about the binary's signature, not about the caller's identity). See Process integrity protection.
Privileges (pipeline steps 4 and 9) #
Did the caller need a privilege to get the right? For example, the right is ACCESS_SYSTEM_SECURITY (SACL access), or the DACL granted nothing and the caller would need SeBackup to read.
Check:
- Is the privilege present on the token?
- Is it enabled?
- Did the caller pass the appropriate intent flag (
BACKUP_INTENT,RESTORE_INTENT)?
If any of those is no, the privilege did not fire. The fix depends on which:
- Privilege absent → authd's privilege policy does not grant this privilege to this principal. A policy-level change, not an access-control change.
- Privilege present but disabled → the calling code should enable it via AdjustPrivileges before the operation.
- Privilege enabled but no intent flag → the calling code should pass the flag in
privilege_intentto AccessCheck.
The DACL walk (pipeline step 8) #
Run the DACL walk manually, paying attention to:
- Ordering. Is the DACL in canonical order (explicit deny, explicit allow, inherited deny, inherited allow)? An out-of-order DACL produces surprising first-writer-wins results.
INHERIT_ONLYACEs. Are there ACEs that look relevant but have theIOflag set? They are skipped during the walk.- The matching identity. Does the caller match the SIDs in the ACEs? Remember:
- The token's
user_sidmatches. - Each group in
groupswithSE_GROUP_ENABLEDset matches for allow ACEs. - Groups with
SE_GROUP_USE_FOR_DENY_ONLYset match for deny ACEs only. - The well-known SIDs Everyone, Authenticated Users, etc. match according to their semantics.
OWNER RIGHTSandPRINCIPAL_SELFmatch per the virtual group injection rules.
- The token's
- Conditional ACEs. If any ACE has a conditional expression, evaluate it against the token's claims, the object's resource attributes, and the local claims (if any). A conditional that should evaluate TRUE but evaluates UNKNOWN (because a referenced attribute is missing) does not grant access for an allow ACE.
After the walk, the result is the bits the DACL would grant. If the requested right is missing from this result, the DACL is the denial.
Narrowing layers (pipeline steps 10–12) #
If the DACL granted the right but the final result does not have it, a narrowing layer stripped it. Check each:
- Restricted-token pass. If
restricted_sidsis non-empty, walk the DACL using only those SIDs. If the restricted-only result lacks the right, the restricted pass stripped it. (Privileges restored after this pass — privilege-granted bits are immune.) - Confinement. If the token is confined, walk the DACL using
confinement_sidandconfinement_capabilities. If that result lacks the right, confinement stripped it. (Privileges are not restored — they can be lost here.) - CAAP. If the SACL has
SYSTEM_SCOPED_POLICY_ID_ACEentries, look up each referenced policy and evaluate its rules. If any rule's effective DACL would not have granted the right, CAAP stripped it. The recovery policy applies if a referenced policy is missing from the kernel cache.
Audit (pipeline steps 13–14) #
If the steps above did not produce a clear answer, check the audit log. The access check emits audit events that record:
- The requested mask, the granted mask.
- Which privileges contributed (and whether they survived).
- The matched ACE (for SACL-driven audits).
- The subject and process.
A privilege-use event with success=false tells you a privilege fired and was stripped — that points at confinement/CAAP/PIP. An access-audit event with success=false and a DACL-walk trigger tells you the DACL did not grant. The audit trail is often the fastest way to localise a denial.
A compact checklist #
When a denial happens, in order:
- Find the thread (
tid,pid). - Read the effective token. Note
user_sid,integrity_level,restricted_sids,confinement_sid,impersonation_level, and which privileges are present-and-enabled. - Read the object's SD. Note the owner, the DACL contents (with attention to order and
INHERIT_ONLY), the mandatory label, the PIP label, and any CAAP references or conditional ACEs. - Walk the pipeline mentally against these inputs.
- Check the audit log if the manual walk does not point at a single layer.
Most denials are diagnosed at step 4 with one of the six categories listed at the top. The remainder are either edge cases (a conditional ACE evaluating UNKNOWN because of a missing claim, a CAAP policy with a misconfigured applies-to expression) or programming bugs (the wrong token installed, an incorrect intent flag, a hand-built SD with bad ordering).
When to use the access-check syscall directly #
For complex investigations, calling kacs_access_check directly is the precise tool. The syscall takes the token, the SD, the desired mask, and all the optional parameters (privilege_intent, self_sid, local_claims, object_audit_context, pip_type/pip_trust). It returns the granted mask, the continuous-audit mask, and the staging-mismatch flag.
You can call it from a debugging tool with the exact inputs the failing code used and see what comes back. The granted mask tells you which bits ended up granted; the audit emissions tell you which layers fired. A denial that is inscrutable from logs becomes visible from a manual access-check invocation with a known set of inputs.
This is how authoritative diagnoses work for the hard cases: reproduce the access check inputs, call the syscall manually, examine the output.
Where to go next #
For the inspection surfaces this walk relies on — reading tokens, sessions, and process state on a live system — read Inspecting tokens, sessions, and processes.
To rehearse an access check from a shell against a real file and token, read The sd command.
Central access policies
Peios / Peios Security Fundamentals / Central Access Policies
A central access policy — CAAP for short — is access policy defined once, centrally, and applied to many objects without per-object configuration. Where a DACL says "this is the policy for this object", a CAAP says "this is the policy for every object that references this policy". Objects reference a CAAP by including a SYSTEM_SCOPED_POLICY_ID_ACE in their SACL, naming the policy's SID. When the access check evaluates the object, it looks up each referenced policy and runs its rules in addition to the object's own DACL.
The model is conservative by design: CAAP never widens access — it only narrows. Whatever the object's DACL would have granted, a CAAP can take away. Nothing a CAAP says can let a caller through that the DACL would have denied. This makes CAAP safe to layer: you can apply a policy to many objects without worrying about it accidentally granting access that the underlying ACLs intended to refuse.
This page covers what CAAP is, when it makes sense to use, and the model at a conceptual level.
What CAAP solves #
The problem CAAP exists for: organisations whose access rules span many objects in a way that does not fit the per-object DACL model. Examples:
- "Files classified Top Secret can only be read by users in the Cleared group, regardless of what the file's individual DACL says."
- "Database records belonging to the EU customer base require GDPR-compliant access rules, applied uniformly."
- "Engineering documents must be readable only on devices with up-to-date security software."
In each case, the rule is defined once at the organisational level. Each object that the rule applies to does not need to know the rule itself; it just needs to reference the policy by SID. The policy's content is stored centrally (in the directory or registry, distributed by authd) and applied at access-check time.
If you tried to express these rules directly in object DACLs, you would have to update every covered object whenever the rule changed. With CAAP, the policy is updated once and the next access check on any referencing object sees the new version.
How an object references a policy #
An object opts into a CAAP by including a SYSTEM_SCOPED_POLICY_ID_ACE in its SACL. The ACE has a single SID — the policy's identifier. Multiple SYSTEM_SCOPED_POLICY_ID_ACE entries in one SACL apply multiple policies; each is looked up independently and each contributes its own restriction to the access check.
The SACL is where the reference lives because the policy reference is system-level information — not something the object's owner should be able to override unilaterally. Modifying the SACL requires ACCESS_SYSTEM_SECURITY, which is privilege-gated (see The SACL). An object owner can rewrite the DACL freely; they cannot add or remove CAAP references without administrative authority.
Inherit-only SYSTEM_SCOPED_POLICY_ID_ACE entries are skipped during evaluation, just like other inherit-only ACEs. Their purpose is to propagate the policy reference to child objects at creation time.
The model in one paragraph #
A CAAP is a named bundle of rules. Each rule has:
- An applies-to expression (optional) — a conditional expression evaluated against the object's resource attributes. If it evaluates to TRUE, the rule applies to this object; if to FALSE or UNKNOWN, the rule is skipped. If the expression is absent, the rule applies to every object that references the policy.
- An effective DACL — a DACL whose rules are run against the calling token via a recursive sub-AccessCheck. The result is intersected with the running grant from the rest of the pipeline. A bit granted by the rule's DACL is preserved; a bit not granted is dropped.
- An effective SACL (optional) — audit ACEs merged into the access check's SACL walk, so the policy can contribute its own audit rules.
- A staged DACL and staged SACL (both optional) — proposed replacements for the effective DACL and SACL, evaluated in parallel without affecting access. Used for policy testing. See Staged policies.
The full rule structure is in Policies and rules. The evaluation mechanics — how rules compose, where they fire in the access pipeline, the no-recursion rule — are in Evaluation.
The narrowing-only guarantee #
Every CAAP rule's effective DACL is intersected with the running grant. The intersection is conjunctive: only bits granted by both sides remain. A bit the rule's DACL would not grant is dropped, regardless of how the object's own DACL had decided it.
This makes CAAP safe by construction. You cannot accidentally write a CAAP that grants something. The most permissive CAAP rule possible would be one whose effective DACL grants GENERIC_ALL to Everyone — and even that does not grant anything to a caller whose underlying DACL did not. The intersection is the floor of safety: CAAP can only ever restrict, never relax.
The implications:
- Adding a CAAP to an existing object cannot increase access. Worst case, the new policy contributes nothing additional. Best case, it restricts access in the intended way.
- A misconfigured CAAP cannot grant. It can only deny too aggressively. A bug in a policy's effective DACL means objects become harder to reach, not easier.
- The order of CAAP evaluation does not matter. Intersection is commutative. Two CAAP rules on one object produce the same final grant whether you evaluate rule A then rule B or B then A.
This is the property that makes CAAP suitable as a deployment-wide policy mechanism. Administrators can layer policies without coordinating because the layers can only narrow.
Multiple policies, multiple rules #
A single SACL can include several SYSTEM_SCOPED_POLICY_ID_ACE entries, each referencing a different policy. Each policy can contain several rules. All of them apply. The running grant from the rest of the pipeline is intersected against each rule's effective DACL in turn. The final grant is what survives every applicable rule across every applicable policy.
In practice, a single object is usually covered by zero or one policy. Multiple policies on the same object means the policies cover orthogonal axes — "Classification" plus "Compliance", say — where each axis is a separate policy. The CAAP author keeps each policy's rules focused on one axis and lets layering produce the combined effect.
Check-at-open semantics #
CAAP changes are applied at the next access check. Already-open handles do not see CAAP changes. A handle opened ten minutes ago has its granted mask cached on the file descriptor; the access check that granted it was the one that mattered, and a CAAP update afterwards has no effect on that handle.
This is the standard check-at-open model that File access (FACS) uses for all of access control. A user wanting to be sure a CAAP change has taken effect must close existing handles and reopen.
The implication for policy rollout: updating a CAAP does not immediately revoke access from sessions that already have open handles. If the policy update is meant to be enforced retroactively, the deployment needs an out-of-band step — typically session-revocation or service-restart — to drop the existing handles.
The CAAP catalog lives in the kernel #
The kernel maintains a policy cache keyed by policy SID. The cache is populated by authd at startup, and updated by kacs_set_caap calls when policies change. At access-check time, looking up a referenced policy is a constant-time lookup against this cache.
The cache is empty at boot. Until authd populates it, every CAAP reference resolves to "policy not found" and the recovery policy fires (covered in Distribution and recovery). This is why authd populates the cache early in boot, before user-facing services start.
Where to start #
If you want the structure of a policy — the rule layout, applies-to expressions, effective and staged DACL/SACL — read Policies and rules.
If you want to know how CAAP fires in the access pipeline and the rules for composition, read Evaluation.
If you want to understand the testing model — staged policies that evaluate in parallel without affecting access — read Staged policies.
If you want the operational story — how policies get into the kernel, who calls kacs_set_caap, what happens when a referenced policy is missing — read Distribution and recovery.
Policies and rules
Peios / Peios Security Fundamentals / Central Access Policies
A central access policy is a structured bundle: a version byte, a count of rules, and the rules themselves. Each rule is independently scoped — its applies-to expression decides whether this rule fires on this object — and contributes its own access restriction and audit rules. A policy with three rules can apply to one object via one rule, to another object via two rules, and to a third object via none, depending on the objects' resource attributes.
This page walks through the structure: what a policy contains, what a rule contains, and how the parts fit together. The mechanics of how the policy is actually evaluated during AccessCheck live in Evaluation.
A policy in shape #
A policy in its on-wire form is a versioned binary blob:
| Field | Meaning |
|---|---|
| Version | A single byte. v0.20 uses 0x01. Any other value is rejected at ingestion. |
| Rule count | A 32-bit little-endian count of the rules that follow. Bounded to 256 rules per policy. |
| Rules | Length-prefixed sequence — one entry per rule. |
A policy is not a thing the kernel walks through ACE by ACE. It is a list of rules, each evaluated as its own unit. Two rules in the same policy do not influence each other; either both apply (their applies-to expressions both evaluate TRUE) or one applies or neither.
The kernel holds the parsed policy in its policy cache, keyed by the policy's SID (the SID is established at the time the policy is pushed via kacs_set_caap — see Distribution and recovery).
A rule in shape #
Each rule is a small structured record:
| Field | Meaning |
|---|---|
applies_to | Optional. A length-prefixed conditional expression in the same bytecode used by callback ACEs. If present, the expression must evaluate TRUE for this rule to apply to a given object; if absent, the rule applies to every object that references the policy. |
effective_dacl | A length-prefixed binary DACL. Required (a rule with no DACL would do nothing). Maximum 65,535 bytes. |
effective_sacl | Optional. A length-prefixed binary SACL contributing audit and policy ACEs. |
staged_dacl | Optional. A proposed replacement for effective_dacl, evaluated in parallel for testing. |
staged_sacl | Optional. Same idea for effective_sacl. |
Length-prefixed means each part starts with a 32-bit count of bytes followed by that many bytes. An absent field has length zero. The wire-format details are in the Wire formats reference; for this page, what matters is the structure.
A rule with no applies-to expression and only an effective_dacl is the most common shape. It says "apply this DACL to every object that references this policy".
The applies-to expression #
The applies_to expression is a conditional-ACE bytecode expression — the same model as conditional ACEs in DACLs and SACLs (see Conditional ACEs). The expression has access to four namespaces:
@User.<name>— claims on the calling token.@Device.<name>— claims on the calling token's machine identity.@Resource.<name>— resource attributes on the object being accessed.@Local.<name>— per-call attributes the caller supplied.
The expression evaluates to TRUE, FALSE, or UNKNOWN. The CAAP rule:
| Result | Effect on the rule |
|---|---|
| TRUE | The rule applies. The effective DACL is evaluated. |
| FALSE | The rule is skipped. |
| UNKNOWN | The rule is skipped. |
The UNKNOWN behaviour is opposite to the conditional-ACE deny rule. In a DACL, an UNKNOWN expression on a deny ACE causes the deny to apply (fail-closed on deny). For a CAAP applies-to, UNKNOWN causes the rule to be skipped (fail-open on the rule's existence). The reason: a rule that does not apply means the running grant is not further restricted; skipping is the conservative choice for CAAP, because the absence of a restriction is the safe default.
This split is worth remembering. The rule of thumb: UNKNOWN always errs in whichever direction does not narrow access. In DACLs, that means UNKNOWN denies are denials (because denying is the existing direction the ACE is going); in CAAP applies-to, that means UNKNOWN rules are skipped (because applying the rule would further narrow access).
Common applies-to expressions:
| Example expression | What it does |
|---|---|
| (absent) | Rule applies to every object that references the policy. |
@Resource.Classification == "TopSecret" | Rule applies only to objects whose Classification resource attribute is TopSecret. |
@Resource.Department == "Engineering" && @Resource.Sensitivity == "Internal" | Rule applies only when both attributes match. |
Exists(@Resource.RetentionUntil) && @Resource.RetentionUntil > @Local.Now | Rule applies only to objects whose retention period has not expired. |
The expression can reference any combination of the four namespaces. It produces a single boolean; whatever combination of attributes you need to inspect, the expression is the place to do it.
The effective DACL #
The effective_dacl is a regular DACL — an ACL of ACEs, with the same format any other DACL uses (see ACLs, ACEs, and access masks). It is evaluated via a recursive sub-AccessCheck at policy-evaluation time, producing a granted mask for the calling token.
The mask is then intersected with the running grant from the rest of the pipeline. The intersection is the narrowing: bits granted by the rule's effective DACL are preserved; bits not granted are dropped.
The rule's effective DACL can contain any ACE types a regular DACL can. Conditional ACEs work — referencing claims, attributes, and local context. Object ACEs work — scoping by property GUID. Generic rights work — mapped via the object type's GenericMapping table at evaluation time, exactly as they would be in a regular DACL.
What the effective DACL cannot do: include SYSTEM_SCOPED_POLICY_ID_ACE references to other policies. Even if such ACEs were present, the kernel's no-recursion rule strips them before evaluating the synthetic SD (see Evaluation). CAAP rules do not nest.
The maximum size of an effective DACL is 65,535 bytes — the same limit a regular DACL has.
The effective SACL #
The effective_sacl is optional. When present, it contributes its ACEs to the access check's SACL walk for the object being evaluated. This lets a policy contribute audit rules that fire alongside the object's own audit ACEs.
The audit ACEs in an effective SACL fire based on the final access decision, not on the policy's contribution to it. An audit ACE in an effective SACL that matches a successful access fires as a "successful access" event, regardless of whether the success came from the object's own DACL or from any specific policy.
This is the audit half of CAAP. A policy that wants to log every access to objects it covers — for regulatory or compliance reasons — does so by including an audit ACE in its effective SACL. The audit ACE applies wherever the rule applies, without the object's own administrator needing to add an audit ACE on every object.
The effective SACL can also contain SYSTEM_MANDATORY_LABEL, SYSTEM_PROCESS_TRUST_LABEL, and SYSTEM_RESOURCE_ATTRIBUTE ACEs — but they are not respected at policy evaluation. CAAP can only contribute to audit. It cannot apply a mandatory label or PIP label to an object through this channel. Those entries, if present, are ignored.
Staged DACL and SACL #
The staged_dacl and staged_sacl fields hold proposed replacements for the corresponding effective entries. They are evaluated in parallel during AccessCheck but do not affect the granted mask. Their purpose is to let administrators test policy changes against real traffic without committing.
If the staged DACL would have produced a different granted mask, or the staged SACL would have produced a different set of audit events, the access check sets a staging mismatch flag in its output. Tools watching for mismatches can use the flag to discover where a proposed policy change would have changed behaviour.
The full mechanics — when staging is used, what the mismatch flag actually contains, how rollout proceeds — are in Staged policies.
A complete rule, in shape #
Putting it all together, a CAAP rule for "TopSecret objects can only be read by users in the Cleared group" might look like:
| Field | Value |
|---|---|
applies_to | @Resource.Classification == "TopSecret" |
effective_dacl | An ACL containing: ACCESS_ALLOWED Cleared-Group GENERIC_READ, and nothing else. |
effective_sacl | An ACL containing: SYSTEM_AUDIT_ACE Everyone GENERIC_READ with `SUCCESSFUL_ACCESS_ACE_FLAG |
staged_dacl | (absent) |
staged_sacl | (absent) |
A user not in the Cleared group attempting to read a TopSecret object: the rule applies (attribute matches), the effective DACL grants GENERIC_READ only to the Cleared group, the user is not in the group so the rule grants nothing, the intersection drops GENERIC_READ from the running grant. Access denied. The audit ACE in the effective SACL records the attempt.
A user in the Cleared group attempting to read the same object: the rule applies, the DACL grants GENERIC_READ to the Cleared group, the user is in the group, the rule contributes GENERIC_READ to the intersection, the running grant survives, access succeeds. The audit ACE still records the access.
A user (anyone) attempting to read an object whose Classification is "Internal", not "TopSecret": the rule's applies-to evaluates FALSE, the rule is skipped, the running grant passes through unchanged. CAAP contributes nothing on this access.
Limits #
For reference, the size and count limits the kernel enforces at ingestion:
| Limit | Value |
|---|---|
| Wire-format spec size | 256 KB |
| Rules per policy | 256 |
| Bytes per applies-to expression | 64 KB |
| Bytes per effective or staged DACL/SACL | 65,535 (matches the SD format limit) |
| Version byte | Must be 0x01 |
A policy exceeding any of these is rejected by kacs_set_caap with -EINVAL. The limits are generous; a realistic policy will be far below them.
Where to go next #
For how a policy's rules are actually evaluated during AccessCheck — the flow, the no-recursion rule, and the composition — read Evaluation.
For the staged DACL and SACL fields and the rollout pattern they enable, read Staged policies.
For the byte-level layout these structures are encoded in, read CAAP format.
Evaluation
Peios / Peios Security Fundamentals / Central Access Policies
CAAP evaluation is step 12 of the access-check pipeline. By this point the DACL walk has run, owner implicit rights have been applied, restricted-token and confinement passes have done their work, and the running grant is whatever survived. CAAP can only narrow this grant further — it never adds.
The mechanism is the same shape every narrowing layer uses: re-walk the DACL against a different identity (or DACL), intersect, move on. The difference for CAAP is what gets walked: each referenced policy's rules are evaluated, and each applicable rule's effective DACL is intersected with the running grant.
This page covers the evaluation flow, the no-recursion rule that prevents CAAP from referencing other CAAPs, and the composition with the rest of the pipeline.
The evaluation flow #
flowchart LR
A["Running grant after step 11"] --> X["For each SYSTEM_SCOPED_POLICY_ID_ACE in SACL"]
X --> L["Look up policy in cache"]
L -->|found| R["For each rule in policy"]
L -->|not found| Y["Apply recovery policy"]
R --> T["Evaluate applies-to"]
T -->|TRUE| E["Evaluate effective DACL against token"]
T -->|FALSE / UNKNOWN| S["Skip rule"]
E --> I["Intersect with running grant"]
Y --> I
I --> N["Next rule or policy"]
N --> F["Final running grant after step 12"]
In code-shape order:
- Scan the object's SACL for
SYSTEM_SCOPED_POLICY_ID_ACEentries that are not inherit-only. - For each such ACE, take the SID and look it up in the kernel's policy cache.
- If the policy is not in the cache, apply the recovery policy — a hardcoded fallback that grants
GENERIC_ALLonly toBUILTIN\Administrators,SYSTEM, andOWNER_RIGHTS. The intersection happens just like any other CAAP rule's would. See Distribution and recovery. - If the policy is in the cache, iterate through its rules.
- For each rule, evaluate the
applies_toexpression against the caller's token, the object's resource attributes, and the local claims supplied by the caller. If the expression evaluates to TRUE, the rule applies; if FALSE or UNKNOWN, the rule is skipped. - For each applicable rule, evaluate the
effective_daclagainst the calling token. This is a recursive sub-AccessCheck — the rule's DACL is walked against the token the same way a regular DACL would be. - Intersect the rule's grant with the running grant. Bits not present in both are dropped from the running grant.
- Collect the rule's
effective_saclfor the eventual audit walk at step 14. - Also evaluate the staged DACL/SACL if present, in parallel. The staged evaluation does not affect the running grant — it only contributes to the staging mismatch flag if its result differs. See Staged policies.
- Move on to the next rule (or, when all rules are evaluated, the next policy).
- After every applicable rule from every applicable policy, the final running grant is what survives. This becomes the input to step 13.
The order does not affect the result. Intersection is commutative — two CAAP rules applied in either order produce the same final grant — so the kernel can evaluate rules in whatever order is convenient.
The recursive sub-AccessCheck #
When the kernel evaluates a rule's effective DACL, it constructs a synthetic SD to run the AccessCheck against:
- The synthetic SD's DACL is the rule's
effective_dacl. - The synthetic SD's owner is the object's actual owner. (Owner implicit rights still apply if the caller is the owner — a CAAP cannot suppress owner implicit grants.)
- The synthetic SD's primary group is the object's actual primary group.
- The synthetic SD's SACL is stripped of any
SYSTEM_SCOPED_POLICY_ID_ACEentries before evaluation. This is the no-recursion rule.
The recursive AccessCheck then runs against this synthetic SD with the same token and the same desired mask. It produces a granted mask. That mask is what gets intersected with the running grant.
The no-recursion rule #
The synthetic SD's SACL has its SYSTEM_SCOPED_POLICY_ID_ACE entries stripped before evaluation. This is what stops a CAAP from referencing another CAAP.
Without this rule, a CAAP rule's effective DACL could contain — or its synthetic SD's SACL could imply — references to additional policies, which would themselves be evaluated, which could reference further policies, and so on. The chain has no natural bound. The kernel addresses this by simply refusing to follow any CAAP reference inside a CAAP evaluation: the synthetic SD's SACL has its scoped-policy ACEs erased, so the recursive AccessCheck sees no CAAP references.
A reasonable question: what if a CAAP author wants their policy to compose with another? The answer is to put both SYSTEM_SCOPED_POLICY_ID_ACE entries in the object's SACL, not nest them. The two policies will be evaluated as siblings — each is intersected with the running grant — and the order does not matter because intersection is commutative. The effect is the same as nesting would have produced but without the recursion concern.
Composition with other narrowing layers #
CAAP is the last narrowing layer in the pipeline. By the time it runs at step 12:
- The DACL walk (step 8) has produced the initial grant from the object's own DACL.
- The restricted-token pass (step 10), if active, has intersected against the restricted-SID-only view.
- The confinement pass (step 11), if active, has intersected against the confinement identity.
CAAP then intersects against each applicable rule's effective DACL. The running grant survives all four (the DACL walk plus three intersections) if and only if every layer permitted the bit.
The composition is conjunctive: each layer must independently grant the right for it to remain. Adding CAAP to a token already restricted and confined further narrows; it cannot bring back access the earlier layers stripped.
Privilege grants are subject to CAAP intersection, like other bits. A CAAP rule whose effective DACL does not grant a right will strip that right from the running grant even if a privilege had granted it in step 4 or step 9. CAAP is privilege-blind in the same way confinement is.
See Narrowing layers for the full composition.
CAAP and audit #
The audit contribution of CAAP — the effective_sacl of each applicable rule — is collected during step 12 but not consumed until step 14, the SACL audit walk. The audit ACEs in CAAP effective SACLs are added to the object's own SACL ACEs, and the audit walk treats the combined set as a single SACL for the purposes of deciding which events to fire.
Audit ACEs in a CAAP can target the same identities and produce the same kinds of events as regular SACL audit ACEs. The CAAP contribution does not get its own event type or its own audit log entry — the events look like any other audit events, except that the matched ACE happens to have come from a policy rather than the object's own SACL.
This means the audit pipeline does not need to know about CAAP specifically. From the perspective of an audit consumer, the events are the same. The provenance — which ACE matched, whether that ACE was on the object or in a CAAP — is recorded in the event's trigger field for consumers that care.
The full audit model is in Auditing.
Errors during evaluation #
A handful of failure modes can occur during CAAP evaluation. The kernel handles each one specifically:
| Condition | Behaviour |
|---|---|
| Referenced policy SID not in cache | Apply the recovery policy. The running grant is intersected with what the recovery policy would grant. The recovery policy is administrator-friendly: it grants GENERIC_ALL to Administrators, SYSTEM, and OWNER_RIGHTS only. |
| Policy in cache but malformed | The same as not in cache — recovery policy applies. (kacs_set_caap should reject malformed policies at ingestion, but defence in depth.) |
| Applies-to expression returns UNKNOWN | Rule is skipped. (Different from a deny-ACE's UNKNOWN, which would apply — see Policies and rules for the asymmetry rationale.) |
| Effective DACL evaluation produces an error | Treated as "this rule denies everything except privilege-granted bits". The running grant is reduced to whatever privileges had pre-decided as granted. |
The pattern: errors fail-closed (i.e. they reduce the running grant, not extend it). A malformed policy or a corrupted effective DACL cannot accidentally grant access. The worst case is denial of access, with the recovery policy ensuring administrators retain a way back in.
Check-at-open #
CAAP evaluation runs only during AccessCheck calls. A handle that was opened ten minutes ago has its granted mask cached on the file descriptor; the access check that decided it ran at open time. A subsequent CAAP update does not retroactively affect that handle.
For FACS-managed objects (files, directories), the granted mask is cached on the open handle and used for subsequent operations against the handle. The mask is not recomputed. See The handle model.
For non-FACS objects that re-evaluate AccessCheck on each operation (some IPC endpoints, some token operations), the CAAP update would be visible immediately on the next operation.
The user-visible effect: changing a CAAP changes what new accesses see. It does not change what existing handles can do. Tools that need a CAAP update to be visible immediately need to coordinate session revocation or service restart — see Distribution and recovery.
Where to go next #
For the parallel evaluation of proposed policy changes and the staging mismatch flag, read Staged policies.
For how policies reach the kernel's cache and what happens when a referenced policy is missing, read Distribution and recovery.
For how the CAAP intersection composes with the restricted-token and confinement passes, read Narrowing layers.
Staged policies
Peios / Peios Security Fundamentals / Central Access Policies
Rolling out a change to a central access policy is operationally risky in a way that local DACL changes are not. A DACL change affects one object; if it is wrong, one object misbehaves. A CAAP change affects every object that references the policy — potentially thousands of files, registry keys, or service endpoints. An overly-permissive change is a security incident; an overly-restrictive change is a system-wide outage.
Staged policies are the testing primitive that makes CAAP rollout safe. A CAAP rule can carry, alongside its effective_dacl and effective_sacl, a proposed replacement called the staged_dacl and staged_sacl. During AccessCheck, the kernel evaluates both versions in parallel. The effective version is what affects the granted mask; the staged version does not. But the kernel records whether the staged version would have produced different behaviour, and reports that as a staging mismatch flag in the access check's output.
A staging mismatch is a signal: "if this staged change had been live, this access would have gone differently". Administrators can collect mismatches over a representative period of access traffic and make an informed decision about whether to commit the staged version.
This page covers how staging works mechanically, what the mismatch flag means, and the rollout pattern it enables.
How staging fits into evaluation #
When the kernel evaluates a CAAP rule at step 12 of the pipeline:
- The rule's applies-to expression is evaluated. If it returns FALSE or UNKNOWN, the rule is skipped entirely; no effective evaluation, no staged evaluation.
- If the applies-to returned TRUE, the
effective_daclis evaluated against the calling token. The result is intersected with the running grant. - If the rule has a
staged_dacl, the kernel also evaluates it against the same token. The result is not intersected with anything; the running grant uses only the effective result. - The kernel compares the staged result against the effective result. If they differ — that is, the staged DACL would have granted a different mask than the effective DACL did — the access check's staging mismatch flag is set.
- The same comparison happens for the
staged_saclversuseffective_sacl: if the staged audit ACEs would have produced different events (a different set of audits fired, or different bits triggered them), the staging mismatch flag is also set.
The mismatch flag is a boolean per access check. It does not say which rule or policy caused the mismatch; that level of detail would require richer reporting. What it does say is: somewhere in the CAAP layer of this access check, a staged change would have made a difference.
Tools watching the flag can correlate it with the call's parameters (which token, which object, which requested rights) to localise where staging would have changed behaviour. This is enough for an administrator to make a go/no-go decision on a policy rollout.
What the mismatch flag is and is not #
The flag is informational. It does not change behaviour — the access check returned the granted mask the effective policy produced, and the caller's operation proceeds accordingly. The flag is for observers (audit systems, deployment tools, monitoring dashboards).
The flag is boolean. It says "yes, somewhere a staged version differed from the effective" or "no, the staged versions all produced identical results". It does not enumerate which rules differed or which audit events changed; the accompanying caap-policy-diagnostic event records only the effective and staged total granted masks. Richer staging diagnostics are a job for the test pipeline (running representative AccessCheck calls and recording the differences); the kernel's report is the existence of a difference, not its detail.
The flag is per access check. Each access check call produces its own flag value in its own output. Aggregating mismatches across many calls is the consumer's job.
The DACL mismatch #
A DACL staging mismatch is set when the staged_dacl produces a different granted mask than the effective_dacl for the same call.
The comparison is bit-exact. If the effective DACL would grant FILE_READ_DATA | FILE_READ_ATTRIBUTES and the staged DACL would grant only FILE_READ_DATA, that is a mismatch. If both would grant the same bits, no mismatch. Bits not present in either grant are irrelevant.
The asymmetry is honest: a staged DACL more permissive than the effective would also trigger a mismatch. Staging is not about "this would deny more"; it is about "this would behave differently".
The SACL mismatch #
The SACL mismatch is more subtle because SACL evaluation produces audit events, not granted bits. The staged_sacl mismatch is set when the staged SACL would have produced a different set of audit events for the same call than the effective SACL did.
"Different set" means: the union of events that would have fired from the staged SACL is not equal to the union from the effective SACL. An event in one but not the other, or vice versa, is a mismatch. This includes audit ACE matches that would have triggered, and conditional audit ACEs whose conditional expressions differ.
The mismatch does not record which events differed. The kernel compares the two event sets internally and discards them; the caap-policy-diagnostic event (kind = staging-mismatch) it emits carries only the effective and staged total granted masks and an object_results_differ boolean. For a pure SACL mismatch those masks may even be equal — the flag is the only signal that the audit behaviour differed.
The rollout pattern #
The intended deployment flow for a CAAP change:
- Author the staged change. Take the existing policy. Add or modify the
staged_dacland/orstaged_saclto reflect the proposed change. Leave the effective versions unchanged. - Distribute the policy. authd pushes the policy (with staged versions populated) to the kernel via
kacs_set_caap. Other machines in the deployment get the same policy through their authd instances. - Run for a period. The system runs normally. Every access check produces a granted mask; tokens behave as if the effective version is in force. The mismatch flag is set whenever the staged version would have produced different behaviour.
- Collect mismatch reports. A monitoring tool records mismatches and their contexts (which call, which token, which object, which requested rights). Administrators review the report.
- Decide. If the mismatches reflect intended behaviour changes (the change tightens access in exactly the cases the administrator expected to tighten), the change is good. If unexpected accesses are flagged, the change has unintended side effects and needs revision.
- Commit. Replace the policy with one where
effective_daclandeffective_saclhold what the staged versions held. Push viakacs_set_caap. The change is now live; subsequent access checks use the new effective version. - Optionally, drop the staging fields. Once the change is committed, the policy can be re-pushed without staged fields, or with new staged fields representing the next planned change.
This flow lets administrators see the effect of a CAAP change on real traffic before committing. The mismatch flag turns "what will this change do?" from a question requiring synthetic test data into an observation of the actual system.
Staging applies to one rule, not whole policies #
Each rule in a policy can have its own staged DACL and SACL. The staging is per-rule, not per-policy. A policy with three rules can have one rule staging a change while the other two stay aligned (no staged version, or staged equal to effective). The mismatch is set whenever any active rule's staged version differs from its effective version.
Operationally, this is exactly what you want. A policy administrator typically wants to stage one change at a time, not stage the whole policy. The rule-level granularity lets each change be reasoned about independently.
What staging does not do #
A few clarifications:
- Staging does not affect access. The staged DACL never contributes to the granted mask. Whatever the effective version returns is what the caller gets.
- Staging does not affect audit events. The staged SACL does not fire events; only the effective SACL does. The staged SACL is evaluated only for the comparison.
- Staging is not versioning. There is no "previous staged" or "next staged". Each rule has at most one staged DACL and one staged SACL. Replacing them replaces them.
- Staging is not a rollback mechanism. If a committed change turns out to be wrong, the rollback is to re-author the policy with the old version and push it again. The staging fields are forward-looking, not backward-looking.
How staging changes for the audit consumer #
An audit consumer that wants to use staging meaningfully should look for the staging mismatch flag in audit events. The flag's presence is a signal to investigate; the access in question would have been decided differently under the proposed change.
Aggregating mismatches over time and bucketing them by (token, object class, requested rights) gives the administrator a picture: "the proposed change would have denied N accesses, of which M look intentional and (N-M) look unintentional". From there, deciding whether to commit is straightforward.
Without staging, the equivalent diagnostic is impossible without either a parallel test system or an A/B rollout — both expensive. Staging makes the comparison free per access check.
Where to go next #
For how a committed policy is pushed to the kernel — and the recovery behaviour when a referenced policy is missing — read Distribution and recovery.
For the caap-policy-diagnostic event that carries the staging mismatch, read Events and transport.
Distribution and recovery
Peios / Peios Security Fundamentals / Central Access Policies
A central access policy that no one has loaded into the kernel does not affect access checks. The kernel maintains a policy cache — a map from policy SID to parsed policy — that AccessCheck consults at step 12. Distributing a policy means getting it into this cache. The mechanism is the kacs_set_caap syscall, called by authd.
This page covers how policies get into the kernel, what happens when a referenced policy is missing, and the boot-time sequence that makes the model usable.
kacs_set_caap #
The syscall is straightforward:
kacs_set_caap(policy_sid, sid_len, spec_or_null, spec_len)
| Parameter | Meaning |
|---|---|
policy_sid | The SID identifying the policy. A 4–68 byte binary SID. Establishes (or replaces) the cache entry. |
sid_len | The length of the SID in bytes. |
spec_or_null | The wire-format spec to install at this SID. NULL means "remove the policy at this SID from the cache". |
spec_len | The length of the spec in bytes. Zero when removing. |
The call requires SeTcbPrivilege. The only callers in a running system are authd and peinit; ordinary processes cannot push policies.
The kernel:
- Validates the SID and the spec. A malformed spec (wrong version byte, truncated fields, too many rules, invalid applies-to bytecode) is rejected with
-EINVAL. No partial installation; the call is atomic. - Parses the spec into the cache's internal representation.
- Replaces any existing entry at the policy's SID with the new one. If no entry existed, creates one.
- Returns success.
The policy is now live. The next access check that references this policy SID will see the new version. Existing handles (their access checks already done) are unaffected, per the check-at-open model.
To remove a policy, the caller passes NULL for the spec. The kernel deletes the cache entry. Subsequent references to this SID will not find the policy and the recovery policy will apply.
Who calls kacs_set_caap #
authd is the primary caller. authd's job is the bridge between policy storage (the registry on a standalone machine, the domain's directory on a domain-joined one) and the kernel. On startup:
- authd connects to its source of policy. Standalone: it reads from the registry. Domain-joined: it queries the domain's directory service.
- For each policy in the source, authd parses the policy into the wire format and calls
kacs_set_caapto install it. - After each policy change in the source (a registry write, a directory replication), authd re-pushes the affected policy via
kacs_set_caap.
peinit can also call kacs_set_caap for early-boot policies that need to be in place before authd starts. This is rare — most of the time, the kernel's CAAP cache is empty until authd takes over.
There is no path for an ordinary application to push a CAAP. The privilege requirement (SeTcbPrivilege) and the architectural restriction (only authd and peinit hold it) mean policy authority is concentrated in one place.
The policy cache and its lifetime #
The cache is an in-kernel data structure with the following properties:
- Empty at boot. The kernel does not persist the cache across reboots. Every boot starts with an empty cache.
- Populated by call. authd (or peinit) pushes policies one at a time via
kacs_set_caap. - Updated in place. A subsequent push at the same SID replaces the existing entry.
- Removed by null spec. A push with a NULL spec deletes the entry.
- Lives for the lifetime of the running kernel. A reboot starts over.
The cache is keyed by SID. There is no other index — no name, no category, no version. The SID is the canonical reference, and it must match between the object's SYSTEM_SCOPED_POLICY_ID_ACE reference and the cache entry.
What happens when a policy is missing #
A common case during early boot, or when a policy reference outlives the policy itself (the policy was removed but objects still reference it): the SACL of an object contains a SYSTEM_SCOPED_POLICY_ID_ACE whose SID does not appear in the policy cache. The kernel needs to do something.
The choice between failing open (treat the missing policy as "no restriction") and failing closed (treat the missing policy as "deny everything") is a security/usability trade-off. Failing open would let administrative misconfiguration silently disable security policies. Failing closed would render objects unreachable when a policy is briefly unavailable.
The kernel picks neither extreme. It applies a recovery policy — a hardcoded fallback that grants GENERIC_ALL only to:
BUILTIN\AdministratorsSYSTEMOWNER_RIGHTS(the owner of the object, but only if not suppressed by an OWNER RIGHTS ACE in the object's DACL)
The recovery policy is conservative in the sensible direction: ordinary users lose access to objects whose policy is missing, but administrators and SYSTEM retain enough authority to investigate, fix, and restore the policy. Owners can still reach their own objects (with the OWNER RIGHTS caveat).
The recovery policy fires automatically when the kernel cannot find a referenced policy. It applies the same way any other CAAP rule would — its grant is intersected with the running grant from the rest of the pipeline. The intersection ensures that the recovery policy cannot grant more than the DACL would have. In the typical case, a non-administrator user attempting to access an object covered by a missing policy gets nothing (DACL + recovery intersect to empty); an administrator gets whatever the DACL would have given them.
The recovery policy is not configurable. It is hardcoded into the kernel. The administrator-and-SYSTEM-only grant is the safe default; administrators who want a different fallback fix the policy distribution problem, they do not change what the recovery policy is.
The boot sequence #
The kernel's CAAP cache is empty at boot. Until authd starts and populates the cache, every access check that touches a CAAP-referencing object will get the recovery policy.
This is by design but it has a consequence: security-sensitive services that depend on CAAP must not start before authd is running. If a service that handles user requests starts before authd has loaded its policies, the service's access checks will use the recovery policy. For ordinary users accessing the service, that means denial of service. For administrators, that means working but not in the way the intended policies define.
The standard boot order:
- Kernel initialises. SYSTEM token created. Anonymous token created.
- peinit starts. peinit may push early-boot policies via
kacs_set_caapif any exist. - authd starts. authd connects to its policy source and pushes every policy via
kacs_set_caap. - authd signals that policy distribution is complete (typically by reaching a steady state).
- Other services start, knowing the CAAP cache is now populated.
The fourth and fifth steps require coordination — peinit, the service manager, and authd need to agree on when CAAP is ready. The exact mechanism is part of Boot and trust establishment.
If your service-start ordering does not respect this, the symptoms are: access denied for ordinary users, mysterious "policy not found" recoveries in the audit log, services that work for administrators but not for anyone else.
Updates and replication #
When a policy changes in the source (an administrator edits a registry value, or the directory replicates a new version), authd notices the change and re-pushes the affected policy via kacs_set_caap. The cache entry is updated atomically: subsequent access checks see the new version, ones already in progress see whichever version was current at the time they evaluated the relevant step.
There is no kernel notification mechanism for "this policy changed". Tools that want to react to a CAAP update need to subscribe to authd's events or watch the source directly. The kernel itself does not announce.
Replication across machines is authd's job, not the kernel's. The kernel knows nothing about other machines; it sees only the policies authd has pushed locally. On a domain-joined system, each machine's authd reads from the directory independently and pushes to its local kernel. The directory's replication latency becomes the propagation delay for CAAP changes across the deployment.
Cache eviction is administrative #
The kernel does not evict cached policies on its own. A policy stays in the cache until:
kacs_set_caapis called with the same SID and a NULL spec (explicit removal).- The kernel reboots (cache is wiped).
There is no LRU, no size-based eviction, no automatic cleanup. The cache grows or shrinks based only on explicit calls. This is intentional: a policy disappearing from the kernel cache because of memory pressure would be a security issue.
If memory is genuinely tight, the right behaviour is to keep the policies and reduce something else. The kernel's CAAP cache is not where the system's memory pressure should land.
What CAAP does not get from distribution #
A few things distribution does not handle, that are worth knowing:
- Object-side enforcement. Distributing a policy makes the policy reachable when an object's SACL references it. The SACL still has to be set up — adding a
SYSTEM_SCOPED_POLICY_ID_ACEto an object's SACL is a separate administrative action on each object (or a class of objects via a directory-management tool). - Audit propagation. A policy's audit events fire as part of the access check; they go to KMES like any other audit event. The distribution mechanism does not bundle a separate audit pipeline.
- Per-user policies. A policy that should apply only to specific users is one whose effective DACL or applies-to expression filters by user identity. Distribution does not target specific users; every policy in the cache applies to whoever accesses an object that references it.
These are policy-author concerns, not distribution concerns. The distribution layer is simple: push policies, identified by SID, into a cache the kernel consults. Everything else is the policy author's responsibility.
Where to go next #
For what the kernel does with a cached policy on each access check, read Evaluation.
For the boot-time coordination that decides when the CAAP cache is ready, read Boot and trust establishment.
File access
Peios / Peios Security Fundamentals / File access
FACS — the File Access Control Shim — is the kernel layer that applies KACS access control to files. Where the kernel's access pipeline runs against any protected object, FACS is the specific bridge between the file system (inode, dentry, page cache) and the access check. Every read, write, open, and metadata operation on a file goes through FACS at some level; FACS decides whether the operation proceeds and, when relevant, what flags or rights apply.
The model FACS uses is the handle model: AccessCheck runs once at open time, the granted mask is cached on the file descriptor that comes back, and every subsequent operation through that fd reads from the cache rather than re-running the access check. The implications of this single design choice ripple through every page in this topic.
This page covers the model at a conceptual level. Later pages cover the handle model in detail, the syscalls for opening files (native and legacy), the SD management operations, and the special cases that the model has to accommodate.
The handle model in one sentence #
Access is decided at the moment a file descriptor is opened; from then on, the descriptor carries an immutable "granted access mask" that gates every operation.
That sentence is the whole model. Once a file is open, the kernel knows what the caller is allowed to do with this file via this handle. Reads, writes, metadata queries, mmaps — each operation has a required access mask (what it needs to do its work) and succeeds only if the required mask is a subset of the cached granted mask.
The mask does not change. Adjustments to the file's DACL, to the calling token, or to anything else after the open do not affect the cached mask. The handle is a snapshot of what the access check said at open time.
The full mechanics — what gets cached, what operations consult the cache, what is and is not subject to the model — are in The handle model.
Why the handle model #
Two main reasons:
Performance. Re-running AccessCheck on every read or write would be expensive. The DACL walk plus narrowing layers plus possibly CAAP evaluation is non-trivial work. Caching the result on the handle reduces operations to a single bitmask comparison — orders of magnitude cheaper.
Consistency. A long-running operation should not change behaviour halfway through because the DACL was modified. A file being read for backup should not start failing midway because an administrator tightened the DACL; either the backup tool has the right at the start (in which case it should complete) or it doesn't (in which case it shouldn't have been able to open in the first place).
The trade-off is that an open handle survives a policy change. If you tighten a file's DACL, processes that already have the file open keep their existing access. New opens get the new policy; old handles are unchanged.
This is the check-at-open principle — covered for CAAP in Central access policies and applied uniformly here. The kernel does not retroactively revoke open handles.
Where FACS sits #
FACS lives between the file system and the rest of KACS. The arrangement:
flowchart LR
A["Userspace syscall (open, read, write)"] --> B["FACS"]
B -->|at open| C["AccessCheck"]
B -->|cached mask on fd| D["File operation"]
C --> D
D -->|persisted| E["Filesystem (ext4, xfs, etc.)"]
At open, FACS calls AccessCheck and caches the result on the fd. At every subsequent operation, FACS reads the cached mask and decides. The filesystem itself doesn't see KACS; it sees only operations that FACS has already vetted.
This means:
- The DACL and the filesystem are decoupled. FACS is what enforces KACS on files; the filesystem stores the SD as metadata (an xattr, typically) but does not interpret it.
- A filesystem can be FACS-managed or not. Mount policy determines whether FACS applies to a given mount; see Mount policies.
- Different filesystems can store SDs differently. ext4, XFS, NTFS — each has its own xattr or native mechanism for persisting the SD. FACS handles the abstraction; the access check works the same way regardless.
Two open syscalls #
The kernel exposes two ways to open a file:
| Syscall | Mode | Common use |
|---|---|---|
kacs_open | KACS-native — caller specifies an explicit desired access mask | New code; programmatic file access by services |
openat / open | Legacy — caller specifies POSIX flags (O_RDONLY, O_WRONLY, etc.) that map to access masks | Existing Linux applications |
kacs_open is the native interface. The caller specifies exactly which rights it wants and either gets all of them or fails. openat and friends are the POSIX-compatibility interface — they use the same FACS machinery internally but with a different mapping from input flags to access mask and with split "core" and "compat" semantics for handling partial grants.
Both paths converge in FACS. The cached mask on the resulting fd is the same shape regardless of which syscall produced the open.
The two interfaces are covered in detail in Opening files.
What FACS reads from where #
A summary of the data sources that feed into a FACS access check at open:
| Source | What it provides |
|---|---|
| Calling thread's effective token | Identity for AccessCheck — user SID, groups, integrity, privileges, etc. |
| File's inode | The owner SID, primary group, DACL, SACL (read via xattr or filesystem-native channel) |
| Mount policy | Whether FACS applies at all, how missing SDs are handled, the mount-level SD template |
| Process PSB | PIP fields for the dominance check during AccessCheck |
Caller's kacs_open parameters | The desired access mask, the create disposition, optionally a creator-supplied SD |
These come together at open. AccessCheck runs over them and produces the granted mask that goes on the fd.
What FACS does not do #
A few clarifications:
- FACS does not store SDs. The filesystem stores SDs; FACS reads them. Different filesystems use different mechanisms (xattrs on ext4 / XFS / Btrfs, native security streams on NTFS, mount-level synthesis on FAT/exFAT, in-memory on tmpfs).
- FACS does not validate SDs at boot. The first access to a file is when its SD is first read and validated; the kernel does not pre-scan filesystems looking for problems.
- FACS does not propagate DACL changes. Modifying a file's DACL affects future opens, not existing handles. This is the check-at-open rule.
- FACS does not gate operations that bypass the file system. A process that has a file mapped into memory via mmap can read or write that memory without going through FACS for each access — the access check happened at mmap time (or at open, then at mmap), and after that the kernel has no efficient way to intercept memory accesses. Mitigations like LSV apply at the mmap call; runtime memory access is governed by the page-table permissions the kernel set when the mapping was created.
Where to start #
If you want the handle model in detail — what is cached, what operations check the cache, when the cache can be stale, how fd transfer works — read The handle model.
If you want the open syscalls — kacs_open for KACS-native, openat for legacy compatibility, the differences in semantics and the rules for each — read Opening files.
If you want to read or modify a file's SD — kacs_get_sd, kacs_set_sd, the security_information bitmask, the rules for setting owner/DACL/SACL/label — read Managing file security.
If you want the edge cases — O_PATH, the exec dual gate, append-only files, sticky bit, POSIX ACLs that no longer work, NFS dual authority — read Special cases.
The handle model
Peios / Peios Security Fundamentals / File access
The handle model is the rule that an open file descriptor carries a fixed snapshot of access permissions, taken at the moment of open. Every operation through the fd consults this snapshot — not the file's current SD, not the calling token's current state, not anything else. The snapshot is the granted access mask, an immutable 32-bit value attached to the fd.
This page covers what the snapshot contains, which operations consult it, why immutability is the right choice, and the implications for handle transfer between processes.
What is on the fd #
When open succeeds, the kernel attaches several things to the resulting fd. The KACS-relevant pieces:
| Field | Set at | Mutable after open? |
|---|---|---|
| Granted access mask | AccessCheck at open | No |
| Continuous audit mask | AccessCheck at open (if alarm ACEs matched) | No |
| FACS-managed flag | Open path | No |
| (Filesystem state: position, mode, etc.) | open | Mutable per operation |
The granted access mask is the central object. It is whatever AccessCheck decided the caller's effective token was entitled to on this file at this moment. The bits set are the rights the fd holder has; the bits not set are rights they do not have.
The continuous audit mask is the union of all alarm ACEs that matched at open time. Every subsequent operation through the fd will check against this mask and emit a continuous-audit event if the operation's required mask overlaps it. See The SACL and Audit ACEs.
The FACS-managed flag distinguishes fds that go through the handle model from those that don't. O_PATH fds, for example, are not FACS-managed (covered in Special cases).
What operations consult the cache #
Almost every operation on a FACS-managed fd checks the cached mask. The rule: the operation has a required access mask (what it needs the caller to have been granted at open) and succeeds only if required & ~granted == 0 — every required bit is in the granted mask.
| Operation | Required access |
|---|---|
read | FILE_READ_DATA |
pread, readv, process_vm_readv (when reading from the fd as target) | Same |
write | FILE_WRITE_DATA |
pwrite, writev | Same |
| Append-only write (RWF_APPEND, O_APPEND) | FILE_APPEND_DATA (without requiring FILE_WRITE_DATA) |
ftruncate | FILE_WRITE_DATA |
fchmod | FILE_WRITE_ATTRIBUTES |
fchown | WRITE_OWNER (updates only the inert Linux uid/gid; see Special cases) |
mmap(PROT_READ) | FILE_READ_DATA |
mmap(PROT_WRITE) | FILE_WRITE_DATA |
mmap(PROT_EXEC) | FILE_EXECUTE |
fstat | FILE_READ_ATTRIBUTES |
fgetxattr | FILE_READ_EA (for ordinary xattrs; the security namespace is unconditionally denied) |
fsetxattr | FILE_WRITE_EA (same exception) |
fdatasync, fsync | None — these are control operations, not data operations |
flock, fcntl(F_SETLK) | None |
Each operation knows what it requires; the kernel compares against the cached mask and decides.
A subtle case: the operation's required mask comes from the operation's semantics, not from POSIX flags. A read() on a fd opened O_RDONLY needs FILE_READ_DATA because read needs FILE_READ_DATA, not because the fd was opened with O_RDONLY. The two coincide because open(O_RDONLY) requested and got FILE_READ_DATA, but the per-operation check is on what the operation needs, not on what the open requested.
Immutability and why #
The cached granted mask cannot change after open. The kernel does not provide a syscall to refresh it. Modifying the file's DACL does not propagate to existing fds. Adjusting the calling token's privileges does not retroactively re-grant rights on previously-opened fds.
The reasoning is two-fold:
Atomic policy. A long-running operation should not change behaviour partway through. A backup tool that opens a file at time T should be able to complete the read at time T+1 even if the DACL was modified between. Either the right to read was granted at open (in which case the read should succeed) or it wasn't (in which case the open should have failed).
Performance. Caching is the whole point. If the kernel re-evaluated AccessCheck on every read, the handle model would not give any of the performance benefit. The cache is what makes the model work.
The implication: a DACL update is observable only to new opens. Existing handles continue with the rights they had. To force a policy change to take effect, the operator must either close existing handles (or kill the processes holding them) or wait for the handles to be released naturally.
fd transfer preserves the mask #
A file descriptor can be transferred between processes — via dup, fork, SCM_RIGHTS, exec, pidfd_getfd. In every case, the transfer preserves the granted access mask exactly.
A fd passed via SCM_RIGHTS to another process gives that process the same access the originating process had through the fd. The recipient's token is not re-checked at transfer; the granted mask is what counts.
This is intentional. Capabilities are passable. A process that opened a file with broad rights can hand the fd to a helper process; the helper inherits the rights even if the helper's own token would not have granted them.
The model is: opening a file produces a capability (the fd + the cached mask), and capabilities are transferable. The transfer is the way the model expects rights to be delegated.
The corollary: a process should be careful about which fds it shares. Passing a fd to a less-trusted helper grants that helper the rights the fd carries. There is no narrowing-at-transfer mechanism.
A more restrictive variant — handing off only some of the access rights — requires opening a fresh fd with the narrower set. The original fd holder, who presumably has the broader set, can open the file again with a narrower mask and pass that fd.
fork and exec #
fork copies the file table; all fds are inherited by the child along with their cached masks. The child has the same rights on the same files as the parent at the moment of fork.
exec (with no special flags) preserves the file table; fds carry over to the new program with their cached masks intact.
exec with FD_CLOEXEC set on a fd causes that fd to be closed at exec; the child program does not inherit it. This is the standard "close on exec" mechanism, used widely. A handle that should not survive exec gets FD_CLOEXEC either at open (via O_CLOEXEC) or later (via fcntl(F_SETFD, FD_CLOEXEC)).
Within the same process, threads share the file table; a fd opened in one thread is usable from any thread in the same process.
The cached mask follows the fd everywhere the fd goes.
When the cache can be wrong #
The cached mask is a snapshot at open time. There are a small number of cases where the cache may be wrong relative to the world at large:
- The DACL was changed after open. The cache reflects the DACL at open; the on-disk DACL may now grant different rights. New opens would get the new rights; this fd still has the old. Not a bug — the model is check-at-open.
- The calling token's state changed. The token's privileges may have been adjusted, the integrity level may have changed (via NEW_PROCESS_MIN at exec, say). The cached mask reflects the token at open. Operations through this fd use the cached value, not the current token state.
- The file was deleted and replaced. An
unlinkof the path the fd was opened against does not affect the fd; the inode remains alive as long as the fd is open. The cache still reflects what was true of the now-unlinked inode. A new file created at the same path is a different inode; the cache is unaffected. - Mount policy changed. Changing the mount policy on a superblock does not retroactively affect the cached masks of fds open against files on that superblock. The cache is the snapshot; mount policy is the input to future opens.
In each case, the model's answer is the same: the cache is the truth for this fd's purposes. If you need a fresh view, close and reopen.
Operations not subject to the cache #
A handful of operations bypass the cache or use a different check:
execveat(AT_EMPTY_PATH)is the one exception in v0.20. Exec re-evaluates AccessCheck rather than using the cache. The reasoning is that exec changes the calling process's identity and PSB in ways that the cached open-time decision did not account for; running a fresh check makes the exec decision honest. This is the only v0.20 use-time access check.- O_PATH fds are not FACS-managed and have no cached mask. Operations against them either work unconditionally (
fstat) or use a fresh AccessCheck (operations that need real access). - Mapping a file's data into kernel space directly (e.g. via
splicebetween two fds) involves both fds' cached masks; neither is bypassed.
The exception list is short. For the overwhelming majority of operations, the cached mask is the gate.
Implications for processes #
Knowing the handle model has practical implications for code that handles files:
Open with exactly the rights you need. A fd opened with broader rights than needed is a broader capability than needed. If something might pass the fd elsewhere, the something gets the broader rights too. Minimum-rights opens are good hygiene.
Pass fds carefully. A fd shared via SCM_RIGHTS is a capability transfer. Audit who you pass to and what they could do with it.
Use FD_CLOEXEC for fds you don't want exec to inherit. This is also a defence-in-depth measure against accidental capability leakage to child processes.
Refreshing access requires reopening. A program that wants to see DACL changes needs to close and reopen, not poll for changes.
Where to go next #
For the two syscalls that produce these fds and stamp the granted mask, read Opening files.
For reading and writing the SD behind a handle, read Managing file security.
For the edges of the model — O_PATH, the exec dual gate, append-only handles — read Special cases.
Opening files
Peios / Peios Security Fundamentals / File access
There are two ways to open a file in Peios. kacs_open is the native interface — the caller specifies an explicit desired access mask, and the kernel either grants all of it or fails the open. openat (and the older open) is the legacy POSIX interface — POSIX flags map to access masks, and the kernel uses split "core" and "compat" semantics so that rights requested but not granted can sometimes be silently dropped without failing the open.
This page covers both syscalls, the create-disposition options, the MAXIMUM_ALLOWED mode, and the rules around the caller supplying an SD for newly-created files.
kacs_open — KACS-native #
kacs_open is the canonical Peios open syscall:
fd = kacs_open(dirfd, path, &how, sizeof(how), &status)
Where how is a kacs_open_how struct:
| Field | Meaning |
|---|---|
desired_access | The 32-bit mask of rights the caller wants. May include MAXIMUM_ALLOWED. |
create_disposition | One of SUPERSEDE / OPEN / CREATE / OPEN_IF / OVERWRITE / OVERWRITE_IF. |
create_options | Modifier flags — DIRECTORY, DELETE_ON_CLOSE. |
flags | Additional flags — AT_EMPTY_PATH, AT_SYMLINK_NOFOLLOW. |
sd_ptr, sd_len | Optional creator-supplied SD for new files. |
The kernel:
- Resolves the path.
- Decides whether the file exists / should exist based on
create_disposition. - Runs AccessCheck with the calling token, the file's SD (or computes one for a newly-created file), and the
desired_accessmask. - Strict-mode semantics: every bit in
desired_accessmust be granted. If any requested right is not granted, the open fails with-EACCES. There is no "partial open" — either every requested bit is in the granted mask or the operation fails entirely. - On success, returns the fd with the granted mask cached. If
statuswas non-null, writes one of OPENED / CREATED / OVERWRITTEN / SUPERSEDED to it indicating what happened.
The strict-mode semantics are the right behaviour for native code. A caller that asks for read-and-write either gets both or fails; ambiguity is removed. The caller knows exactly what rights it has on the resulting fd because the mask is exactly what was requested.
The MAXIMUM_ALLOWED flag (covered below) is the exception — when set, the kernel relaxes strict mode and returns whatever rights the caller could have gotten.
kacs_open_how — fields in detail #
desired_access #
The 32-bit access mask. Standard format: object-specific bits 0–15, standard bits 16–20, special bits 24–25, generic bits 28–31. The kernel expands generic bits (GENERIC_READ, GENERIC_WRITE, etc.) using the file GenericMapping at evaluation time.
Special rights:
MAXIMUM_ALLOWED(0x02000000) — request the maximum grantable mask. See below.ACCESS_SYSTEM_SECURITY(0x01000000) — the right to read or modify the SACL. Gated by SeSecurityPrivilege.
A desired_access of zero is rejected — the kernel does not grant a fd with no rights.
create_disposition #
Defines what to do depending on whether the file exists (numeric constant values: Other constants):
| Value | If file exists | If file does not exist |
|---|---|---|
SUPERSEDE | Delete and recreate. | Create. |
OPEN | Open. | Fail with ENOENT. |
CREATE | Fail with EEXIST. | Create. |
OPEN_IF | Open. | Create. |
OVERWRITE | Truncate to zero and open. | Fail with ENOENT. |
OVERWRITE_IF | Truncate to zero and open. | Create. |
OPEN_IF is the most common ("get me access to this file, creating if necessary"). SUPERSEDE removes the inode and creates a new one — useful for atomic-replace patterns.
create_options #
Modifier flags:
| Flag | Meaning |
|---|---|
DIRECTORY | The target must be (or will be created as) a directory. If the disposition would create and this flag is set, a directory is created. If the disposition is open-only and the target is not a directory, fails with ENOTDIR. |
DELETE_ON_CLOSE | The file should be deleted when the last fd referencing it is closed. Useful for temporary files. |
flags #
Path-resolution flags:
| Flag | Meaning |
|---|---|
AT_EMPTY_PATH | The path is empty; operate on the directory referenced by dirfd. |
AT_SYMLINK_NOFOLLOW | Do not follow a terminal symlink. If the path resolves to a symlink, fails with ELOOP. |
sd_ptr / sd_len #
Optionally, the caller can supply a security descriptor for a newly-created file. The SD must be in self-relative format and within the standard size limit (65,535 bytes).
The rules for the creator-supplied SD:
- Permitted on a disposition that creates (CREATE, OPEN_IF when the file does not exist, OVERWRITE_IF when it does not exist, SUPERSEDE when it does not exist).
- Rejected on open-existing branches (OPEN, OPEN_IF when the file exists, OVERWRITE when the file exists). Supplying an SD on an existing-file path is an error (-EINVAL).
- If not supplied for a creation, the kernel synthesises an SD from the parent's inheritable ACEs and the creator's token defaults (see Inheritance).
- The caller must be entitled to set the owner SID — the same rule as
kacs_set_sd(own SID or a SE_GROUP_OWNER group, or SeRestorePrivilege to override).
status #
Optional output indicating what happened:
| Value | Meaning |
|---|---|
OPENED (1) | An existing file was opened. |
CREATED (2) | A new file was created. |
OVERWRITTEN (3) | An existing file was truncated and opened. |
SUPERSEDED (4) | An existing file was deleted and a new file was created. |
This is useful for OPEN_IF / OVERWRITE_IF dispositions where the caller wants to know what happened. For non-conditional dispositions, the answer is predictable from the disposition itself.
MAXIMUM_ALLOWED — relax strict mode #
When MAXIMUM_ALLOWED (0x02000000) is set in desired_access, the kernel changes mode:
- The
MAXIMUM_ALLOWEDflag is stripped from the desired mask. - AccessCheck runs in maximum-allowed mode (see DACL evaluation).
- The full DACL is walked, accumulating every right the caller could have been granted.
- The result is returned as the cached granted mask. It is not compared to the desired mask for strict-mode purposes.
Using MAXIMUM_ALLOWED, the open succeeds with whatever rights the caller could have gotten, including possibly fewer than what they "asked for". The other bits in desired_access are treated as a hint about what the caller would want — the kernel still evaluates them — but the call does not fail if the caller's actual rights are narrower.
The flag exists for tools that want to do "open with whatever access I can get" rather than "open with exactly these rights". A backup tool, for example, might want to open every file it encounters with whatever access is available rather than refusing the file because it cannot get write.
MAXIMUM_ALLOWED must be combined with at least one concrete data or execute bit. Calling with MAXIMUM_ALLOWED alone is rejected with -EINVAL — the kernel needs to know that some operation is intended, even if the specific rights are flexible.
openat (legacy) — POSIX-compatibility #
The legacy openat (and the older open) takes POSIX flags and maps them to access masks. The mapping is:
| POSIX flag | Maps to |
|---|---|
O_RDONLY | FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE (the read category from GENERIC_READ) |
O_WRONLY | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EA | READ_CONTROL | SYNCHRONIZE |
O_RDWR | O_RDONLY mapping ∪ O_WRONLY mapping |
O_APPEND | Adds FILE_APPEND_DATA |
O_TRUNC | Requires FILE_WRITE_DATA |
O_CREAT | Sets the disposition to OPEN_IF (with the FILE_ADD_FILE right on the parent) |
O_EXCL | With O_CREAT, switches the disposition to CREATE (fail if exists) |
O_PATH | Special — see Special cases. The fd is not FACS-managed. |
O_NOFOLLOW | Sets AT_SYMLINK_NOFOLLOW. |
So openat(O_RDONLY) maps to "open with FILE_READ_DATA + the rest of the read category". The kernel then runs AccessCheck for that mask.
Core vs compat rights #
The wrinkle: legacy openat uses a core vs compat split. The mapped rights are partitioned into:
- Core rights — the rights that the operation fundamentally needs. If any core right is denied, the open fails.
- Compat rights — rights that are conventionally granted with this POSIX flag but are not strictly necessary. If any compat right is denied, it is silently dropped from the cached mask — the open still succeeds, just with a narrower grant.
For O_RDONLY, the core right is FILE_READ_DATA (you cannot read without it). The compat rights are FILE_READ_ATTRIBUTES, FILE_READ_EA, READ_CONTROL, SYNCHRONIZE — these are convenient to have, but a read-only open is still meaningful without them.
The result is that legacy open(O_RDONLY) succeeds when FILE_READ_DATA is granted, even if the other read-category rights are not. The fd ends up with the granted subset cached.
This is the difference from kacs_open. kacs_open(FILE_READ_DATA | FILE_READ_ATTRIBUTES) requires both and fails if either is denied; open(O_RDONLY) requires FILE_READ_DATA and silently drops FILE_READ_ATTRIBUTES if it is denied.
The reasoning: POSIX applications expect open to succeed with reasonable defaults. Failing because a niche right was not granted would break compatibility. The split lets the application succeed with the rights it really needs and treat the others as bonuses.
Why kacs_open exists #
If legacy openat works fine for POSIX applications, why have kacs_open?
Two reasons:
Precision. New code that knows what rights it needs should be able to request them explicitly. kacs_open lets a service say "I need exactly these rights" and either get them or be told they are not granted. There is no silent narrowing.
Direct SD provision. kacs_open lets the caller pass an explicit SD for a newly-created file. Legacy openat does not — POSIX open(O_CREAT) uses umask-based defaults plus the parent's inheritable ACEs, with no path for the caller to specify a custom SD.
For most application code, legacy openat is the right tool. For services that care about exact-rights or that need explicit-SD-at-creation, kacs_open is the answer.
Errors #
Both syscalls can fail with:
| Error | Cause |
|---|---|
-EACCES | Strict-mode access check failed (kacs_open), or core rights denied (legacy). Or a related path component is unreachable. |
-EEXIST | The disposition was CREATE and the file already exists. |
-ENOENT | The disposition was OPEN or OVERWRITE and the file does not exist. |
-ENOTDIR | KACS_CREATE_OPT_DIRECTORY was set but the target is not a directory; or a path component is not a directory. |
-ELOOP | AT_SYMLINK_NOFOLLOW was set and the path resolves to a symlink. |
-EINVAL | Various — MAXIMUM_ALLOWED with no concrete bits, an invalid create_disposition, an SD on an open-existing branch, etc. |
-EBADF | The dirfd is invalid. |
The error code tells you what went wrong. The kernel does not give cryptic codes; each maps to a specific named failure mode.
See also #
- The handle model — what the cached mask these opens produce actually does.
- Managing file security — reading and writing the SD behind a handle.
- DACL evaluation — the maximum-allowed walk MAXIMUM_ALLOWED relies on.
Managing file security
Peios / Peios Security Fundamentals / File access
Reading and writing a file's security descriptor is done through two dedicated syscalls — kacs_get_sd and kacs_set_sd — not through the xattr layer. Direct xattr access to the SD storage (the security.peios.sd or system.ntfs_security xattr) is unconditionally denied; the only way through is the syscall pair.
This page covers the syscalls, the security_information bitmask that decides which SD parts are accessed, the access rules, and the special LABEL_SECURITY_INFORMATION flag for setting the integrity label specifically.
Why the xattr layer is denied #
You might expect that the SD, stored in an xattr, would be readable and writable through the standard getxattr / setxattr syscalls. It is not. The kernel refuses every xattr operation on the SD xattr regardless of who is asking and what they hold.
The reasoning:
- Atomic semantics. Reading or writing the SD via xattr would expose the raw bytes; tools could read a partial SD (during a write by someone else), or write an SD whose internal structure is inconsistent with the file's other state.
- Access rule unification. The SD has its own access rules —
READ_CONTROLto read,WRITE_DAC/WRITE_OWNER/ACCESS_SYSTEM_SECURITYto write different parts. Routing throughkacs_get_sd/kacs_set_sdputs these rules in one place; routing through xattr would require duplicating them at the xattr layer. - Format flexibility. Different filesystems store the SD differently. The syscall abstraction lets the kernel translate; the xattr layer would force a specific format.
So the syscall pair is the only path. Reading or writing the SD goes through them; xattr operations on the SD xattr are denied.
kacs_get_sd #
The read syscall:
size = kacs_get_sd(dirfd, path, security_info, buf, buf_len, flags)
Returns the SD bytes for the requested components.
| Parameter | Meaning |
|---|---|
dirfd, path | The file to read. Standard dirfd-relative resolution. |
security_info | A bitmask saying which components to return. See below. |
buf, buf_len | Output buffer. |
flags | AT_EMPTY_PATH (use dirfd as fd-relative), AT_SYMLINK_NOFOLLOW (don't follow terminal symlink). |
The kernel:
- Resolves the path.
- Runs AccessCheck — the caller needs the access rights corresponding to the requested components (covered below).
- Reads the file's SD from its filesystem-native storage.
- Constructs a subset SD containing only the requested components. Other components have offset 0 in the header and the corresponding PRESENT bit is clear.
- Returns the subset SD in
buf, with the total length as the return value.
Probe mode #
Calling with buf_len = 0 (or buf = NULL) is a probe — the kernel computes the size the SD would take and returns it without writing to the buffer. The probe returns the same size value that a non-probe call would; the caller can use this size to allocate exactly the right buffer.
The probe call always returns the size on success. It does not return -ERANGE — the probe is itself a question about size, and answering it is the kernel's job. -ERANGE would be the wrong signal for a deliberate probe.
A non-probe call with buf_len < required returns -ERANGE with the required size written somewhere accessible (typically the same length parameter, or via a separate output). The caller can then reallocate and retry.
Access rules #
Different components need different rights:
Requested component (via security_info flag) | Required right |
|---|---|
OWNER_SECURITY_INFORMATION | READ_CONTROL |
GROUP_SECURITY_INFORMATION | READ_CONTROL |
DACL_SECURITY_INFORMATION | READ_CONTROL |
SACL_SECURITY_INFORMATION | ACCESS_SYSTEM_SECURITY |
LABEL_SECURITY_INFORMATION | READ_CONTROL (the integrity label is in the SACL but its read is gated by READ_CONTROL, not ACCESS_SYSTEM_SECURITY) |
(The numeric flag values are catalogued in Other constants.)
READ_CONTROL is the standard "read SD" right and is implicitly granted to the owner. ACCESS_SYSTEM_SECURITY is gated by SeSecurityPrivilege — the SACL is read-restricted to administrators with the privilege.
A caller asking for components they do not have rights for gets -EACCES. A caller asking for a mix can succeed for the components they can access — but the kernel does this as an all-or-nothing operation: if any requested component fails its access check, the whole call fails.
For combining requested components: just OR the flags. kacs_get_sd(..., OWNER | DACL, ...) returns owner and DACL but not SACL or group.
SACL and LABEL are mutually exclusive #
SACL_SECURITY_INFORMATION and LABEL_SECURITY_INFORMATION cannot be combined in one call. Setting both flags returns -EINVAL. The reasoning: LABEL_SECURITY_INFORMATION is a focused query for just the integrity label (which lives in the SACL); it has different access requirements than reading the full SACL. The kernel keeps the two paths separate.
kacs_set_sd #
The write syscall:
result = kacs_set_sd(dirfd, path, security_info, sd_buf, sd_len, flags)
| Parameter | Meaning |
|---|---|
dirfd, path | Target file. |
security_info | Which components to update. |
sd_buf, sd_len | The new SD bytes (self-relative format). |
flags | AT_EMPTY_PATH, AT_SYMLINK_NOFOLLOW. |
The kernel:
- Resolves the path.
- Parses the SD blob. Rejects malformed SDs (size limit, bad ACL structure, etc.) with
-EINVAL. - Runs AccessCheck for the required rights per the components being updated.
- Validates additional rules — owner SID is the caller's own or a SE_GROUP_OWNER group (unless SeRestorePrivilege), MANDATORY-flagged resource attributes are not removed (unless SeTcbPrivilege), integrity label is not raised above caller's own (unless SeRelabelPrivilege).
- Writes the SD to the file's native storage.
- Returns 0 on success.
The write is atomic: either all requested components are updated or none are.
Access rules for writes #
| Component | Required right |
|---|---|
| Owner | WRITE_OWNER (plus the owner SID validation) |
| Group | WRITE_OWNER |
| DACL | WRITE_DAC |
| SACL | ACCESS_SYSTEM_SECURITY |
| LABEL (integrity label only) | WRITE_OWNER (plus integrity constraint) |
WRITE_DAC is the standard "modify DACL" right, implicitly granted to the owner. WRITE_OWNER is needed to change the owner field (and the validation rules apply per Ownership). ACCESS_SYSTEM_SECURITY is the SACL gate.
The integrity label #
LABEL_SECURITY_INFORMATION (0x10) is the focused write path for setting just the integrity label. The label lives in the SACL as a SYSTEM_MANDATORY_LABEL_ACE, but setting it via this flag is treated as a separate operation from setting the full SACL — with different access requirements:
- The right needed is
WRITE_OWNER, notACCESS_SYSTEM_SECURITY. - The caller cannot raise the integrity label above the calling token's own integrity level (without
SeRelabelPrivilege). - Lowering the integrity label to at or below the caller's own integrity level is allowed.
LABEL_SECURITY_INFORMATION and SACL_SECURITY_INFORMATION cannot be combined in one call — same rule as for reading.
The use case: a process that wants to lower its files' integrity labels without holding SeSecurityPrivilege. The label is in the SACL conceptually, but setting it gets the WRITE_OWNER gate rather than the SACL gate, because adjusting the label down is a less sensitive operation than rewriting the audit policy.
SD parsing and validation #
The provided SD blob must be:
- In self-relative format (
SE_SELF_RELATIVEflag set in control bits). - Within the 65,535-byte size limit.
- Internally consistent — the offsets in the header point to valid locations, the ACLs parse cleanly, the SIDs are well-formed.
Any failure of validation returns -EINVAL. The original SD on the file is unchanged.
Setting only some components #
The security_information flags tell the kernel which components of the provided SD to apply. A blob containing owner + DACL with only DACL_SECURITY_INFORMATION set updates only the DACL; the file's existing owner is preserved.
The blob structure must still be valid — components not being applied are typically absent (offset 0, PRESENT bit clear) in the blob, but the blob's header still needs to be a valid SD header.
This is the pattern for updating one part of an SD without touching the others. Read the SD (probe + fetch), update the relevant component, write back with only that component's flag set.
Ownership transfer rules #
Setting the owner via kacs_set_sd triggers the rules described in Ownership:
- The new owner must be the caller's own user_sid, or a SID in the caller's groups with
SE_GROUP_OWNERset, or the caller must holdSeRestorePrivilege. - The caller must have
WRITE_OWNERon the object, or holdSeTakeOwnershipPrivilege.
A failure here returns -EACCES.
The same rules apply whether you set ownership via OWNER_SECURITY_INFORMATION alone or in combination with other components.
What about file mode (the POSIX rwx bits)? #
The traditional POSIX file mode — chmod-style read/write/execute bits — does not exist in the same way under FACS. The Linux mode bits are stored alongside the SD as ordinary inode metadata, but they are not consulted by FACS for access control (except the execute bit as an exec prerequisite). The DACL is what decides access; the mode is informational only.
The mode-changing syscalls still work, gated on SD rights:
fchmod()succeeds only if the fd's granted mask includesWRITE_DAC(legacy opens request it as a compat right); otherwise-EACCES(-EBADFon O_PATH fds).chmod()runs a fresh access check requiringWRITE_DACon the file's SD.- In both cases the mode bits are updated but the SD is untouched. To change what actually decides access, call
kacs_set_sdwith a new DACL.
The mode is inert metadata; the SD is the truth.
Errors #
Common errors from both syscalls:
| Error | Cause |
|---|---|
-EACCES | Access check failed for the requested components. |
-EINVAL | Malformed SD blob, invalid security_information combination, size limit exceeded, or other validation failure. |
-EPERM | Owner SID validation failed without SeRestorePrivilege, or integrity label too high without SeRelabelPrivilege, or attempted MANDATORY attribute removal without SeTcbPrivilege. |
-ERANGE | (kacs_get_sd only) Buffer too small; required size returned. |
-ENOENT | The target path does not exist. |
-ELOOP | AT_SYMLINK_NOFOLLOW and the path is a symlink. |
Most failures are diagnostic and clear from the error code.
See also #
- Ownership — the owner-transfer rules kacs_set_sd enforces.
- The sd command — the shell wrapper for these syscalls.
- Opening files — supplying an SD at file creation instead.
Special cases
Peios / Peios Security Fundamentals / File access
The handle model handles most of file access cleanly. But files have edges — operations that bypass the model, semantics that don't fit, filesystems whose behaviour FACS cannot fully control. This page covers those edges.
Each of the special cases below is something that someone debugging FACS behaviour will eventually hit. Knowing the rules ahead of time keeps the gotchas from being surprises.
O_PATH — fds without a cached mask #
O_PATH is a Linux flag for opening a file in a "path-only" mode. The fd that comes back can be used to refer to the file (for openat(dirfd, ..., 0, fd), for fstatat, for path manipulation) but cannot be used for actual data operations.
Under FACS, an O_PATH open does not run a full AccessCheck. The fd is not FACS-managed; it has no cached granted mask. Operations that would normally consult the cache either work unconditionally (because they don't need any access) or use a fresh live AccessCheck (because they do):
| Operation on O_PATH fd | Behaviour |
|---|---|
fstat, fstatat | Works without access check. The fd is a path reference; getting stat info is allowed. |
openat using the fd as dirfd | Normal — runs AccessCheck for the new open. The dirfd's O_PATH status doesn't affect the new fd. |
kacs_get_sd with AT_EMPTY_PATH | Runs a live AccessCheck (the fd has no cached mask to consult). |
kacs_set_sd with AT_EMPTY_PATH | Same — live AccessCheck. |
read, write, mmap | Denied with EBADF. The fd cannot be used for data operations. |
fchmod, fchown, fgetxattr, fsetxattr, ioctl | Denied with EBADF. |
O_PATH is useful for path manipulation — keeping a reference to a directory while traversing the tree, or referring to a file by fd rather than by path. For these uses, the lack of a cached mask is irrelevant.
The catch: a process that wants to do anything with an O_PATH fd beyond path navigation needs to reopen it. The reopen is a fresh access check that decides what the resulting fd can actually do.
The exec dual gate #
Exec is the only operation in v0.20 that performs a use-time access check rather than relying on the cached mask. The reasoning: exec changes the process's identity (via the new binary's PIP) and effectively replaces it; the open-time decision is no longer the right one to use.
Specifically, execveat(AT_EMPTY_PATH) on an open fd:
- Runs a fresh AccessCheck against the file's current DACL using the caller's current effective token.
- Requires both the
+xmode bit on the file andFILE_EXECUTEon the caller's access to be present.
The two checks answer two different questions:
+xon the file means this file is intended to be run as a program. It is a property of the file itself, set by whoever owns it — the file's author saying "this is an executable, not data". A file without+xis data; trying to exec it fails regardless of who is asking, because exec on data is meaningless.FILE_EXECUTEon the access means this caller is allowed to execute this program. It is an access decision against the DACL — the question of whether this principal, on this token, is authorised to run this binary.
Both have to be true for exec to proceed. A data file (no +x) is not runnable by anyone; an executable file the caller is not authorised for is runnable in principle but not by this caller.
The dual gate is the v0.20 exception to the handle model. The reason for live evaluation here is that exec's consequences are large enough — the binary that runs is what the kernel will then trust at the verified PIP level — that running on a stale cache is the wrong default.
mmap(PROT_EXEC) is different from exec. It uses the cached mask (FILE_EXECUTE from the open), and additionally runs the LSV mitigation if enabled. mmap is not the same as exec; the dual-gate-with-live-check applies only to exec specifically.
Append-only handles #
A handle opened with FILE_APPEND_DATA granted but not FILE_WRITE_DATA is an append-only handle. The kernel enforces this strictly:
write()at the current offset is allowed only if the current offset is at end-of-file. Otherwise denied.pwrite()at any offset other than end-of-file is denied (RWF_APPEND-style).- Mapping the file shared writable (
mmap(MAP_SHARED, PROT_WRITE)) is denied. ftruncateto extend the file is denied (cannot rewind via truncate).fallocatemodes that would mutate (FALLOC_FL_PUNCH_HOLE, FALLOC_FL_COLLAPSE_RANGE, FALLOC_FL_ZERO_RANGE) are denied.
Operations that legitimately only append — POSIX write with O_APPEND, RWF_APPEND writes — work normally. The kernel makes sure the data lands at end-of-file.
The use case: log files that should be append-only. A logger holds a handle with FILE_APPEND_DATA but not FILE_WRITE_DATA; it can write log lines but cannot rewrite earlier ones. Even if the logger is compromised, the existing log entries are safe from modification through this handle.
Append-only is the FACS expression of the "secure log" pattern. It exists at the right-mask level, not as a separate file attribute.
sticky bit — no effect under FACS #
The traditional Unix sticky bit on a directory restricts file deletion within: only the owner of a file (or the owner of the directory) may unlink files in a sticky-bit directory. This is what makes /tmp work.
Under FACS, the sticky bit has no effect. Deletion is gated by FILE_DELETE_CHILD on the parent directory's SD. The sticky bit's semantics are not encoded.
The reason: FACS's access model is comprehensive enough that the sticky bit is unnecessary. A /tmp directory's DACL grants the directory's FILE_DELETE_CHILD to the appropriate principals (typically just the owner or the file owner, mediated through OWNER RIGHTS); this is the same effect the sticky bit had, expressed in the DACL.
For /tmp specifically, the Peios-default DACL provides equivalent semantics: each user can create files in /tmp (FILE_ADD_FILE granted to Authenticated Users), each file's owner can delete their own (FILE_DELETE_CHILD inherited per-file from OWNER RIGHTS ACEs), but a user cannot delete another user's files.
A directory whose mode includes the sticky bit (visible to ls -l as a t) is informational; the mode is derived from the SD by FACS. The bit can be set but has no enforcement consequence.
POSIX ACLs — replaced by KACS #
The Linux POSIX ACLs (set via setfacl and stored as system.posix_acl_access and system.posix_acl_default xattrs) are not honoured by FACS. They are replaced by KACS DACLs.
Specifically:
- Writes to
system.posix_acl_accessandsystem.posix_acl_defaultxattrs are unconditionally denied. - Existing POSIX ACL xattrs on files (carried over from a pre-Peios system, say) are ignored. FACS uses the KACS DACL exclusively.
The kernel will not silently translate POSIX ACLs to KACS DACLs. Migrating from a Linux system with POSIX ACLs requires re-writing the SDs in KACS form. Tools that can do this conversion exist in the migration tooling; the kernel does not do it on the fly.
fchown — gated on WRITE_OWNER #
The legacy fchown syscall changes a file's owner UID/GID. Under FACS, the family is permitted but gated on SD rights:
fchown()succeeds only if the fd's granted mask includesWRITE_OWNER; otherwise-EACCES.chown()andlchown()run a fresh access check requiringWRITE_OWNERon the file's SD.
The Linux uid/gid change does not alter the SD's owner SID. Changing the real owner goes through kacs_set_sd with OWNER_SECURITY_INFORMATION — the KACS semantics for ownership (covered in Managing file security) replace the legacy mode-and-owner duo.
The kernel surfaces the file's owner SID's projected UID as the result of stat-style queries, so ls -l shows a UID. But the UID is derived from the owner SID; setting it via fchown is not the way.
NFS — dual authority #
NFS client mounts are a special case. The underlying filesystem is on a remote server; the server enforces its own access control independent of what the client thinks. FACS on the client side cannot reach into the server's authorisation; it can only express its local view.
The pattern Peios uses: NFS client mounts are configured with the synthesize_ephemeral mount policy. The client synthesises a local SD for each file accessed (per the mount template), runs AccessCheck locally, and either authorises the operation or denies it. If authorised, the operation is forwarded to the server, which may then deny it for its own reasons.
The consequence: a locally-authorised open can still produce I/O errors when the server refuses. A caller may successfully open(O_RDONLY) against an NFS file (FACS's local synthesis decided to grant) but the read() returns -EACCES from the server.
This is "dual authority": the client and the server both have a say, and both have to permit. The client's denial is final on the client side; the server's denial is final on the server side. There is no single source of truth for what is allowed.
Other implications:
- Expect I/O errors from server-side denial. A successful open does not guarantee a successful read. Handle
EACCES(orEIO) from the operation, not just from the open. - The synthesised SD is local. Changes to the file's actual SD on the server are not visible to FACS's local synthesis. Tools that want to inspect the real SD need to query the server.
NFS server mounts (where Peios is the server) work the other direction: FACS enforces the SD on the local files; the protocol exposes it. The remote client's view of what is allowed is whatever the protocol negotiates, which may be limited by NFS-protocol-level restrictions but is ultimately decided by FACS.
/proc and /sys #
/proc and /sys are kernel-managed pseudo-filesystems. They are not FACS-managed in the standard sense — their mount policy is unmanaged, which means the FACS handle model does not apply.
What does happen:
- File operations under
/proc/<pid>/*route through the process's PSB / process SD checks (the two-check rule from PIP). Access is decided per-operation against the target process. /sys/kernel/security/kacs/*files have explicit SDs on them (set up by the kernel) and are accessed via the kernel's own check logic.- General
/procfiles (/proc/uptime,/proc/cpuinfo) are world-readable by convention. /syswrites are restricted toBUILTIN\AdministratorsandSYSTEMby hardcoded rule.
The result: operations on /proc and /sys work, but they don't go through the FACS handle model. The cached mask on the fd from opening one of these files is meaningless; the per-operation check is what gates access.
This is why cat /proc/self/status produces output without any apparent FACS involvement — the access is granted by the kernel's /proc-specific rules, and the kernel just makes data available.
The mount-policy classes are covered in Mount policies. unmanaged is the special class for kernel-managed filesystems; it cannot be set via the public ABI.
Whiteouts in renameat2 #
renameat2(RENAME_WHITEOUT) — the Linux-specific flag for creating a "whiteout" entry as part of a rename (used by overlay filesystems) — is supported on FACS-managed filesystems. The rename and the whiteout it leaves behind happen as one atomic operation, exactly as on stock Linux.
A whiteout is a chrdev(0,0) sentinel that overlay filesystems drop at the old name to mask a file in a lower layer. FACS authorises it the same way it authorises any new node: you need FILE_ADD_FILE on the source directory (where the whiteout lands), in addition to the usual rename rights. If you lack that right the whole rename is denied before anything is created, and the whiteout — like every freshly created node — inherits a security descriptor from its parent directory and is recorded in the audit trail.
This matters if you run overlayfs or union-style filesystems on FACS-managed storage: they work without the partial-support caveats earlier previews carried.
Summary table #
The special cases at a glance:
| Case | What is different |
|---|---|
| O_PATH | No cached mask; data operations denied; live check for SD ops |
| exec via execveat | Live AccessCheck plus Linux +x mode bit |
| Append-only handles | Write-at-end only; mmap-shared-writable and truncate denied |
| Sticky bit | No effect — the DACL is the gate |
| POSIX ACLs | Replaced by KACS; xattr writes denied |
| fchown / fchmod | Gated on WRITE_OWNER / WRITE_DAC; the mode bits are inert — the SD is the truth |
| NFS client | Dual authority; local FACS plus remote enforcement |
/proc and /sys | Unmanaged mount policy; per-operation check by kernel-specific rules |
| renameat2 RENAME_WHITEOUT | Supported; atomic, needs FILE_ADD_FILE on the source directory, whiteout inherits an SD |
Each is a deliberate decision. Some reflect Linux compatibility (POSIX ACLs replaced); some reflect security policy (exec dual gate, append-only enforcement); some reflect the model's limits (NFS dual authority). Knowing them ahead of time keeps them from being surprises.
Where to go next #
For the model these cases are edges of, read The handle model.
For how a mount's policy decides whether FACS applies at all, read Mount policies.
For the SD read/write syscalls referenced throughout, read Managing file security.
Confinement
Peios / Peios Security Fundamentals / Confinement
Confinement is the sandbox model in Peios. A confined application is one whose token carries a confinement identity — a single SID identifying which package or sandbox the application belongs to — and an enumerated list of capability SIDs naming what the sandbox is allowed to reach. The kernel enforces this confinement whether or not the code knows about it: the application cannot opt out, cannot disable it, cannot exercise a privilege to escape it.
Confinement is the answer to a specific question: how do you run code you do not fully trust on a system whose other resources you do trust? The answer is to give the code a token with a confinement identity and capabilities that name only what it should reach. The kernel then performs an absolute intersection: even if the code somehow obtains broader access through normal means, the confinement layer strips it back to what the capabilities permit.
Who confinement is for #
Confinement is administratively applied. A sysadmin deploys a service or installs an application under a confinement policy — the service definition declares the confinement SID and capabilities; the kernel enforces them. The confined code does not write the policy and cannot read it back to change it. From the application's perspective, confinement is just "the kernel will not let me reach X".
This is the key difference from restricted tokens. Restricted tokens are self-imposed — a program calls FilterToken to narrow its own authority before launching a sensitive operation. Confinement is externally imposed — the policy was decided before the application started and cannot be modified by the application.
The two coexist. A confined service can also internally restrict its own tokens for specific worker threads. The kernel applies both layers independently.
The three fields on the token #
Confinement state lives in three fields on the token:
| Field | Meaning |
|---|---|
confinement_sid | The package or sandbox identity. If null, the token is not confined and the confinement pass is a no-op. If non-null, the token is confined and the kernel will enforce. |
confinement_capabilities | An array of capability SIDs declaring what the confined application is allowed to reach. Used during the confinement intersection — entries here are the SIDs that match ACEs on objects the confined application is permitted to touch. |
confinement_exempt | A boolean escape hatch. When true, the confinement pass is skipped entirely. Set very rarely, only for code that legitimately needs to step outside its own confinement (a confined application's privileged helper, in some narrow patterns). |
All three are set at token creation and cannot be changed at runtime. authd is responsible for putting the right values on a confined application's token; once the token is minted, the fields are immutable.
The kernel imposes no special rule on ALL_APPLICATION_PACKAGES (S-1-15-2-1) at token creation: its presence in confinement_capabilities is what selects normal confinement mode, and its absence selects strict mode. The kernel neither synthesises it nor rejects tokens that carry it — authd decides which capabilities a package's token receives. See Capabilities and modes.
The model in one paragraph #
A confined token's effective access is the intersection of:
- What the DACL plus privileges would grant the token's full identity.
- What the DACL would grant a "fresh" caller whose entire identity is the confinement SID plus the declared capability SIDs.
In other words: the confined application has whatever rights its real identity has, and its confinement identity has, on every object it touches. If either side is missing, the right is dropped. The confinement layer is what makes the second condition real — without it, the token's full identity would be all that the access check considered.
The full mechanics of the intersection — when it fires, what it intersects against, what bypasses and what does not — are in The confinement pass. The capability matching specifically (how the SIDs in confinement_capabilities are compared against ACEs in DACLs) is in Capabilities and modes.
Why privileges do not bypass confinement #
The thing that makes confinement different from every other narrowing layer is its treatment of privileges. Restricted tokens preserve privilege-granted bits through the intersection. Confinement does not. A confined token with SeBackupPrivilege enabled and BACKUP_INTENT set still has its read access narrowed by the confinement intersection — the privilege grant survives steps 4 and 9 of the pipeline, then gets stripped at step 11.
The reason is the audience. A program that restricts itself is trusted to use privileges responsibly — it knows what it is doing because it wrote the restriction. A confined application is not trusted in the same way; the confinement policy came from outside, and a privilege that bypassed it would be an escape hatch for the confined code.
Confinement says: this code may run as whoever, with whatever privileges authd granted, but it cannot reach beyond the capabilities the administrator declared. Privileges are not the lever for breaking out.
What confinement is not #
A few things worth clarifying:
- Confinement is not a privilege-removal mechanism. A confined token can still carry privileges. The privileges fire normally in their kernel-standalone uses (a confined application with
SeShutdowncan shut down the system if nothing else gates the call). What confinement narrows is AccessCheck-influencing privileges specifically — the ones that grant bits via AccessCheck. The non-AccessCheck privileges work as usual. - Confinement is not a process-level firewall. It is a token-level intersection that runs during AccessCheck. It does not block network calls, ptrace, signals, or any other kernel surface that does not go through AccessCheck. Other layers (PIP, the process SD, kernel-standalone privilege checks) handle those.
- Confinement is not container isolation. It runs alongside the application's normal access surface, not inside a separate namespace. A confined application sees the same filesystem, the same registry, the same processes as everyone else — it just cannot exercise rights to most of them. Container-style namespace isolation is a separate concern.
- Confinement is not opt-in for the code. The code does not call a "please confine me" syscall. The token arrives confined; the kernel enforces.
When to use confinement vs other layers #
A quick map of when each narrowing layer is the right tool:
| Want this | Use this |
|---|---|
| A long-running service to run with less authority than its account would imply, set by administrative policy | Confinement |
| A program to internally drop authority for a specific sensitive operation, set by code | Restricted token |
| Centrally-defined organisational policy applied across many objects | CAAP |
| Prevent untrusted binaries from interfering with trusted ones | PIP |
| Block writes from lower-integrity to higher-integrity | MIC |
Confinement is for applications and services that need to be sandboxed by administrative decision. The capability model — "this application can reach the network, but not the filesystem outside its own data" — is what confinement expresses well.
Where to start #
If you want the capability model — how confinement capabilities match ACEs, the well-known and derived capability SIDs, and the difference between normal and strict modes — read Capabilities and modes.
If you want the mechanics of the confinement intersection — when it fires in the access pipeline, what does and does not bypass it, the role of confinement_exempt, and the isolation-boundary reservation — read The confinement pass.
If you want to understand the canonical Peios pattern for managing service access at scale — putting capability SIDs into a token's normal groups rather than (or in addition to) confinement_capabilities — read Positive confinement. The pattern is what most non-trivial deployments use, and the name is more misleading than the concept itself.
Capabilities and modes
Peios / Peios Security Fundamentals / Confinement
A confined application's reach is expressed as a list of capability SIDs on its token. Each capability is a named permission to use a kind of resource: network access, removable storage, the certificate store. The kernel matches the capability list against ACEs in the DACLs of objects the application touches; an ACE granting access to a capability the confined application has declared lets the access through, while objects whose DACLs do not mention any declared capability are unreachable.
This page covers the capability SID format, the well-known capabilities, how custom capabilities are derived from names, and the two confinement modes (normal and strict).
What a capability SID looks like #
All capability SIDs sit under the S-1-15-3-* namespace. There are two flavours:
- Well-known capabilities have small numeric sub-authorities (
S-1-15-3-1,S-1-15-3-2, etc.) assigned to specific named permissions. - Derived capabilities have eight sub-authorities computed from the SHA-256 hash of the capability name. The same name always produces the same SID. The hash is split into eight little-endian 32-bit values and appended to
S-1-15-3-.
The format itself does not distinguish the two. They are both S-1-15-3-...-shape SIDs; the access check matches them as plain SIDs.
The well-known capabilities #
| SID | Capability |
|---|---|
S-1-15-3-1 | internetClient — outbound internet access. |
S-1-15-3-2 | internetClientServer — inbound and outbound internet. |
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. |
The SIDs at positions 4 through 7 are reserved and not used in v0.20. The well-known capability set is deliberately small — every additional well-known capability has to be defined globally and means the same thing on every Peios system. Capabilities specific to one application or one environment use the derived form.
The way these SIDs become meaningful is the same as any other SID: they appear in ACEs on objects whose DACLs grant access to them. The system's network stack, for example, has SDs on its endpoints that grant access to internetClient; a confined application carrying internetClient as a capability can reach those endpoints; one without it cannot. The capability SID is the link between the policy on the resource and the declaration on the token.
Derived capabilities #
For capabilities specific to an application or a deployment, the SID is derived from a string:
S-1-15-3-{h0}-{h1}-{h2}-{h3}-{h4}-{h5}-{h6}-{h7}
The eight 32-bit values come from SHA-256(name), split into eight 32-bit little-endian integers in order. The same name always produces the same SID. Two different names (even names that differ in a single character) produce different SIDs with overwhelming probability — SHA-256's collision resistance does the work.
A derived capability is meaningful when:
- The application's manifest declares the capability by name, so authd can compute the SID and put it on the token.
- The resources the capability protects have DACLs containing ACEs that grant rights to the same SID.
Both ends use the same derivation, so they meet at the same SID. The string name is human-readable convention; the kernel only ever sees the derived SID.
Derived capabilities are how an application-vendor or system integrator extends the model without coordinating with the OS. An application that needs access to a vendor-specific resource declares a vendor-specific capability name; the vendor's installer arranges for the resource's DACL to grant that capability; the two sides agree implicitly through the SHA-256 derivation.
Confinement modes — normal vs strict #
The well-known SID ALL_APPLICATION_PACKAGES (S-1-15-2-1) and the related ALL_RESTRICTED_APPLICATION_PACKAGES (S-1-15-2-2) define the two confinement modes. These are not capabilities themselves — they are matchers used in ACEs to grant access to all confined applications, or to confined applications in strict mode.
| Confinement mode | ALL_APPLICATION_PACKAGES matches the token? | ALL_RESTRICTED_APPLICATION_PACKAGES matches? |
|---|---|---|
| Normal | Yes | Yes |
| Strict | No | Yes |
The difference: an ACE granting rights to ALL_APPLICATION_PACKAGES lets every confined application reach the object in normal mode, but not strict-mode applications. An ACE granting rights to ALL_RESTRICTED_APPLICATION_PACKAGES lets confined applications in both modes through.
The mode is a property of the token at creation. It is encoded by whether ALL_APPLICATION_PACKAGES appears as one of the token's confinement_capabilities:
- If
ALL_APPLICATION_PACKAGES(S-1-15-2-1) is present in the token's confinement capabilities → normal mode. - If it is absent → strict mode.
There is no separate mode flag and no rejection rule — the mode is purely the presence or absence of the SID, and the kernel never synthesises or strips it.
The practical effect of strict mode: a strict-mode application can reach only objects whose DACLs explicitly grant access to its capabilities or to ALL_RESTRICTED_APPLICATION_PACKAGES. The much larger set of objects that grant to ALL_APPLICATION_PACKAGES are off-limits.
This is what "strict" means. Most operating-system resources whose policy grants broad access to confined applications use ALL_APPLICATION_PACKAGES; a strict application is choosing to opt out of those broad grants and rely only on its specific named capabilities.
When to choose strict over normal: when the application's threat model says it should not be able to reach resources whose authors granted broad access without specifically intending to include it. The trade-off is operational — strict applications often need their own specific capability grants on every resource they need, which is more work.
Capability matching during the confinement pass #
When the confinement pass (pipeline step 11) re-walks the DACL against the confinement identity, the matching rule is:
- The token's
confinement_sidmatches an ACE whose SID is the same. - Each entry in
confinement_capabilitiesmatches an ACE whose SID is the same. ALL_APPLICATION_PACKAGESmatches an ACE on that SID only if the token's capabilities include it (i.e. only in normal mode).ALL_RESTRICTED_APPLICATION_PACKAGESmatches an ACE on that SID always, in both modes.- Group attributes on the token's capabilities are ignored. The capability list is presence-based — what matters is whether a SID appears, not what its enabled/disabled state is.
The matching is bare SID equality. The capability identity does not carry a privilege bitmask, group membership semantics, or anything else. It is just a SID that names a kind of resource.
The full mechanics of the pass — what gets intersected, what bypasses — are in The confinement pass.
Capability declaration vs grant #
A small but important distinction: declaring a capability is not the same as having access to the resources it names.
A token's confinement_capabilities is the list of capabilities the confined application is allowed to use. Whether it actually gets access to a specific object still depends on the object's DACL — the capability SID has to appear in an allow ACE.
If a token declares internetClient but no network resource has an ACE granting internetClient rights, the capability declaration achieves nothing. Conversely, if a token does not declare internetClient but some objects have ACEs granting access to that SID, the confined application still cannot reach them — the capability has to be on the token and the ACE has to grant access to it.
The model is conjunctive: both the token-side declaration and the resource-side grant have to agree. Capabilities are a vocabulary; the DACLs are the actual permissions. Without both halves the access does not happen.
This page describes the declaration half — the appearance of capability SIDs in confinement_capabilities, where they are consumed by the confinement pass. Capability SIDs can also appear in a token's groups list, where they are consumed by the ordinary DACL walk and act as positive grants rather than confinement constraints. That convention — positive confinement — is how most non-trivial Peios deployments express service access. The capability vocabulary is the same; the placement on the token decides which mechanism uses it.
Capabilities and other narrowing layers #
Capabilities live in the confinement pass. They do not appear in:
- The normal DACL walk (step 8). The DACL walk uses the token's
user_sidandgroups, not itsconfinement_capabilities. Capabilities are invisible to step 8. - The restricted-token pass (step 10). Restricted tokens narrow against
restricted_sids, not against capabilities. A restricted-and-confined token gets both intersections. - CAAP evaluation (step 12). Central access policies do not match capabilities specifically; they match the token's normal SIDs.
The capability SID is only used in step 11. If you are writing an ACE that grants access to a capability, that ACE will only be relevant when a confined token is the caller. For a non-confined token, the ACE is just an entry with a SID nothing in the token matches.
Practical pattern: granting access to a capability #
A typical SD on a resource intended to be reachable by confined applications might look like:
- An ACE granting
BUILTIN\AdministratorsGENERIC_ALL. (Administrative full control.) - An ACE granting
SYSTEMGENERIC_ALL. (System full control.) - An ACE granting
Authenticated UsersGENERIC_READ. (Standard authenticated read.) - An ACE granting
internetClientFILE_READ_DATA | FILE_WRITE_DATA. (Confined applications with the internetClient capability can use it.)
A non-confined token reaches the resource through its normal identity (Authenticated Users, administrative groups, etc.). A confined token additionally needs the capability ACE to match a SID in its confinement_capabilities for the confinement pass to leave the access intact. The two sides — normal identity for the DACL walk, capability for the confinement pass — both have to grant.
The presence of capability ACEs on system resources is what makes confined applications usable in practice. Without them, every confined application would be locked out of everything that did not specifically know about it.
Where to go next #
For the mechanics of how these capabilities are matched at access-check time — what fires, what gets intersected, and what is preserved — read The confinement pass.
For the other use of the same capability SIDs — placed in groups as positive grants — read Positive confinement.
For the wider catalog of system-defined SIDs these capabilities sit alongside, read Well-known principals.
The confinement pass
Peios / Peios Security Fundamentals / Confinement
The confinement pass is the access-check step that enforces confinement policy. It fires at step 11 of the pipeline, after the DACL walk and the restricted-token pass have produced their results. The kernel runs the DACL one more time against the confinement identity — confinement_sid plus confinement_capabilities — and intersects the result with what the rest of the pipeline has so far granted.
The intersection is absolute. Any bit not present in both the running grant and the confinement-only grant is dropped. This is what makes confinement different from the restricted-token pass: there is no "privileges are restored after the intersection" step. What confinement removes stays removed.
This page covers the mechanics of the pass — when it fires, what the secondary walk does, what bypasses it, and the role of confinement_exempt and isolation_boundary.
When the pass fires #
The confinement pass fires when:
- The token's
confinement_sidis non-null, and - The token's
confinement_exemptflag is false.
If either is false, the pass is a no-op and the running grant passes through unchanged.
The pass is per access check. There is no caching of "this token always loses these bits" — the kernel runs the secondary walk every time, because the DACL on the object being accessed determines what the confinement identity would have been granted, and that varies per object.
What the secondary walk does #
flowchart LR
A["Running grant after step 10 (DACL + privileges + restricted intersection)"] --> X["Confinement intersect"]
B["DACL"] --> Y["Walk against confinement identity"]
Y --> X
X --> R["Running grant after step 11"]
The kernel runs a second DACL walk against the same DACL the normal walk ran against. But the matching identity is different:
| Element | Used in secondary walk |
|---|---|
| User SID | Confinement SID, in place of the token's user_sid. |
| Groups | Confinement capabilities (confinement_capabilities list), in place of the token's groups. Group attributes (enabled/disabled) are ignored — presence-based matching only. |
| Restricted SIDs | Ignored. The restricted-token pass already ran in step 10. |
| Owner SID | The object's owner SID, but owner implicit rights are not applied. A confined caller who happens to own the object does not get `READ_CONTROL |
| Mandatory label | The MIC pre-decisions from step 5 are not re-evaluated. They were applied before the DACL walk; they remain applied. |
In strict mode (the token does not carry ALL_APPLICATION_PACKAGES), an ACE granting rights to ALL_APPLICATION_PACKAGES does not match. In normal mode it does. ALL_RESTRICTED_APPLICATION_PACKAGES matches in both modes.
The walk produces a granted_confinement mask. The pipeline then computes:
granted = granted & granted_confinement
Any bit not in both sides drops out. No restoration step follows.
What confinement does not preserve #
The intersection drops bits regardless of where they came from. Specifically:
- Privilege-granted bits are dropped. Bits granted by
SeBackupPrivilege,SeRestorePrivilege,SeSecurityPrivilege, orSeTakeOwnershipPrivilegein steps 4 or 9 are subject to the intersection. If the confinement DACL would not have granted them, they are lost. This is the major distinction from the restricted-token pass. - Owner implicit rights are dropped if the confinement identity is not the owner. Step 8 granted the owner
READ_CONTROL | WRITE_DAC. The confinement secondary walk does not apply owner implicit rights to a non-matching identity — the confinement SID is what is being walked, and it is not the owner. So the owner's implicit rights are lost.
The owner-implicit-rights case is the surprising one. A confined application running as a user who owns an object cannot read or modify the SD of that object unless the DACL specifically grants the confinement identity those rights. The fact that the user is the owner does not help — the confined identity is what counts at step 11.
What confinement does preserve #
A handful of decisions are not re-evaluated in step 11:
- MIC decisions from step 5. Whatever MIC pre-decided as denied at step 5 stays decided. Confinement does not undo MIC; it adds on top.
- PIP decisions from step 5. Same.
- The token's identity for non-AccessCheck purposes. Confinement does not change
user_sidorgroupsfor any kernel API other than AccessCheck. A confined process still appears as its original user ingetpwuid-style queries, in/proc/<pid>/status, and in any other identity-display surface.
The intersection is purely an AccessCheck mechanism. It does not change who the process is; it changes what AccessCheck lets the process do.
confinement_exempt #
The confinement_exempt flag on a token is the escape hatch. When true, step 11 is skipped entirely: the running grant from step 10 passes through unchanged. The token is still confined in the sense that confinement_sid is set — it just is not enforced.
The flag is set very rarely. The intended use case: a privileged helper that runs alongside a confined application and needs to step outside the confinement for specific operations. Both halves share the user identity; the helper has confinement_exempt set so it can reach resources the confined main application cannot.
confinement_exempt is set at token creation by authd and cannot be changed at runtime. Like every other token field, the flag is immutable once minted.
The flag does not affect the rest of the pipeline. Steps 0 through 10 run normally on a confinement_exempt token. The flag short-circuits only step 11. PIP, MIC, the DACL walk, restricted-token narrowing, and CAAP all still apply.
isolation_boundary #
The token has a fourth confinement-related field: isolation_boundary. It is reserved in v0.20 — the kernel reads it but does not enforce it. The semantic the field is reserved for: an additional layer on top of confinement where objects outside the boundary are made invisible rather than just denied. A confined application with isolation_boundary set would see only objects whose policy granted to its boundary; everything else would appear not to exist (rather than appearing and being denied).
The distinction matters in two cases:
- Enumeration. A confined application listing the contents of a directory should see only objects in its boundary, not "denied" entries for objects outside.
- Existence checks. A confined application calling
staton a path outside its boundary should get "no such file" rather than "permission denied".
The full mechanics — what "outside the boundary" means, how it interacts with the FACS handle model, how object enumeration is filtered — are reserved for a future version. In v0.20, isolation_boundary is a no-op; the field is on the token for forward compatibility.
For now, treat it as unused. Tokens that need invisibility-instead-of-denial semantics will need an updated kernel to enforce them.
Composition with other narrowing layers #
A confined token can also be restricted, and can also be accessing a CAAP-bound object. All three narrowing layers (restricted at step 10, confinement at step 11, CAAP at step 12) fire in order. Each is a strict intersection. The final granted mask is the conjunction of:
- Whatever the DACL walk + privileges produced (steps 4–9).
- Whatever the restricted-token walk would have produced (step 10, if
restricted_sidsis non-empty). - Whatever the confinement walk would have produced (step 11, this page).
- Whatever every applicable CAAP rule's effective DACL would have produced (step 12).
For each step that is active, the running grant is narrowed by the intersection. For each that is not, the grant passes through.
The order matters in one subtle way: privileges are restored after the restricted-token pass but not after the confinement pass. A bit that survived step 10 because privileges restored it can still be dropped at step 11 if confinement does not grant it. The privilege rescue is partial.
See Narrowing layers for the composition rules across all three intersections.
A worked example #
A service is deployed under confinement. Its token has:
user_sid = jellyfin_user_SIDgroups = [jellyfin_user_SID, BUILTIN\Users, Authenticated Users, Everyone]confinement_sid = S-1-15-2-<jellyfin-package-hash>(the package identity — a hash-derived SID, not a well-known one)confinement_capabilities = [S-1-15-3-1 (internetClient), S-1-15-3-10 (removableStorage), S-1-15-2-1 (ALL_APPLICATION_PACKAGES — normal mode)]privileges = [SeChangeNotifyPrivilege, SeCreateSymbolicLinkPrivilege](default-grant set; nothing else)
The service tries to open /var/state/services/jellyfin/library.db for reading. The file's SD:
- Owner:
jellyfin_user_SID - DACL:
- ACE 1:
ACCESS_ALLOWED Authenticated Users GENERIC_READ - ACE 2:
ACCESS_ALLOWED ALL_APPLICATION_PACKAGES GENERIC_READ
- ACE 1:
The access check:
- Steps 0–4: no impersonation issue, SD valid, generic mapping expands GENERIC_READ to FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL, no privileges applicable.
- Step 5: no MIC label (default Medium / NO_WRITE_UP — does not block read), no PIP label. Nothing pre-decided.
- Step 6: virtual group injection.
OWNER RIGHTSis added (the token owns the file). But the object's DACL has no OWNER RIGHTS ACE, so the implicitREAD_CONTROL | WRITE_DACwill be granted at step 8. - Step 8: owner implicit grants READ_CONTROL | WRITE_DAC. The DACL walk grants FILE_READ_DATA et al. via ACE 1 (Authenticated Users match). The grant is comprehensive.
- Step 10: restricted-token pass skipped (no restricted_sids).
- Step 11: confinement pass. The secondary walk runs against the confinement identity.
- The confinement SID matches no ACE (the SD has no ACE on the package SID).
- The capability
internetClientmatches no ACE. - The capability
removableStoragematches no ACE. - The capability
ALL_APPLICATION_PACKAGESmatches ACE 2, granting GENERIC_READ-expanded bits. - Owner implicit rights are not applied (the confinement identity is not the user_sid).
- Result:
granted_confinement = {FILE_READ_DATA, FILE_READ_ATTRIBUTES, FILE_READ_EA, READ_CONTROL, SYNCHRONIZE}. - The intersection with the running grant: the running grant had owner-implicit READ_CONTROL | WRITE_DAC plus the user-granted read bits. The intersection keeps the read bits and READ_CONTROL but drops WRITE_DAC (which the confinement identity would not have been granted).
- Step 12: no CAAP.
- Result: the service can read the file but cannot modify its DACL even though it owns the file. The owner implicit grant is gone because confinement does not preserve it.
This is the expected behaviour. The service runs as the user who owns its library file but does not get owner-style authority on the file because the confinement layer specifically removed it.
Where to go next #
For the convention that turns the same capability SIDs into positive grants — the canonical pattern for service access at scale — read Positive confinement.
For how the confinement intersection composes with the restricted-token and CAAP intersections, read Narrowing layers.
Positive confinement
Peios / Peios Security Fundamentals / Confinement
Positive confinement is a convention, not a kernel feature. It is the practice of taking capability SIDs — the same S-1-15-3-* SIDs that name a confinement capability — and placing them in a token's normal groups list rather than its confinement_capabilities list. The kernel does not distinguish capability SIDs from any other SIDs in the groups list; they participate in the ordinary DACL walk like every other group. The result is that the capability acts as a positive grant — "this token has this capability, and any ACE that grants rights to this capability grants those rights to this token" — rather than as a confinement constraint.
The name is awkward. "Positive confinement" is not about confinement at all in the access-narrowing sense. It is named after the kind of SID involved (capabilities, which come from the confinement model), with "positive" marking the opposite effect — grant rather than restrict. Once you have the convention in mind it makes sense; on first encounter the name does not help. Reading it as "positive use of capability SIDs" is the cleanest paraphrase.
This page covers what the convention is, why it exists, and how it composes with standard confinement.
A capability SID is just a SID #
The capability SIDs documented in Capabilities and modes — S-1-15-3-1 (internetClient), S-1-15-3-10 (removableStorage), and the derived capabilities produced from SHA-256 of capability names — are normal Peios SIDs. They follow the SID format; they compare with byte equality; they appear in ACEs like any other SID; they can be present on a token in any of the SID-bearing fields.
The kernel does not have a "capability SID" type tag. The format does not distinguish them from other SIDs. What makes a capability SID a capability is the namespace convention (S-1-15-3-*) and the way administrators choose to use it, not any special handling.
So when you see a capability SID in a token, the question is not "is this a capability". The question is which field on the token is it sitting in. The answer is what determines how the access check uses it.
Placement determines effect #
A token has several SID-bearing fields. Two of them are commonly the home of capability SIDs:
| Field | What happens to a capability SID placed here |
|---|---|
confinement_capabilities | The capability is consumed by the confinement pass at pipeline step 11. The capability matches ACEs only inside the confinement intersection — its role is to narrow what the confined token can reach. |
groups | The capability is consumed by the normal DACL walk at pipeline step 8. The capability matches ACEs in the ordinary first-writer-wins evaluation — its role is to grant the token whatever rights the ACE specifies. |
Standard confinement uses the first column. Positive confinement uses the second. The same SID, the same DACL, but a very different result.
A token can carry the same capability SID in both fields, or in either, or in neither. The kernel does not check for consistency between the two — the fields are independent.
There are also other SID-bearing fields on the token (the restricted_sids list, for example). Capability SIDs are not commonly placed there today, but nothing in the format prevents it; if a future convention emerges, the kernel will treat the SID like any other in that field. The model is open in this respect: a capability SID is reachable by every mechanism that walks any SID list on the token.
The two effects, compared #
A worked-through contrast on the same DACL.
The DACL on a resource contains one ACE:
ACCESS_ALLOWED S-1-15-3-1 (internetClient) GENERIC_READ
Case 1: token has internetClient in confinement_capabilities.
The token's normal identity (user_sid, groups) does not match the ACE, so step 8's DACL walk grants nothing. The token reaches step 11 with an empty grant. The confinement intersection re-walks the DACL against the confinement identity — and the capability list — and finds the ACE matches. The intersection produces a grant of GENERIC_READ-mapped bits. But the running grant from step 8 was empty, so the intersection yields empty too.
Result: no access. The capability said "the confined application is permitted to use this resource if its own identity reaches it", and the token's own identity didn't.
Case 2: token has internetClient in groups.
The token's groups include the capability SID. The normal DACL walk at step 8 matches the ACE against the group SID and grants GENERIC_READ-mapped bits. The confinement pass — if even active on this token — re-walks the DACL against confinement_sid and confinement_capabilities; if the token is not confined, step 11 is a no-op.
Result: access granted. The capability acted as a normal group membership — "the token is a member of the internetClient-bearing group, the DACL grants access to that group, the token gets access".
Case 3: token has internetClient in both.
Step 8 grants because the group SID matches. Step 11 (if confined) re-walks against the confinement identity and finds the same match — the capability is in confinement_capabilities, the ACE grants to the capability, the intersection passes. The grant survives.
Result: access granted, with the confinement intersection satisfied. This is the pattern when a token is genuinely confined and needs to reach a specific capability-gated resource through the confinement layer.
The kernel did the same thing in all three cases — it walked the SIDs it had in the fields it had them in, against the same DACL. The different outcomes are entirely a function of placement.
Why this convention exists #
In a deployment with a handful of services and a few resources, the obvious model is to give each service a dedicated user account and write ACEs naming that user. jellyfin reads /var/state/services/jellyfin/, owned by jellyfin, with a DACL granting jellyfin full access. Add a new service, create a new user, write new ACEs.
At any kind of scale this breaks down. A dozen services each touching a few dozen shared resources is hundreds of ACE entries to maintain, and every new service requires editing the DACL of every resource it touches. Service accounts become a sprawl of nobody-style entries that exist only to appear in ACL lists.
Positive confinement is the alternative pattern. Instead of:
- A
jellyfinuser, mentioned in every media-file DACL. - A
transmissionuser, mentioned in every download-directory DACL. - A
nextclouduser, mentioned in every shared-storage DACL.
You define capabilities — semantic units of access — and grant ACEs to those:
- A
media-library-readcapability. Every media file's DACL grantsGENERIC_READto this capability. - A
download-writecapability. Every download directory's DACL grantsGENERIC_WRITEto this capability. - A
shared-storagecapability. Every shared-storage object's DACL grants to this capability.
Then each service is given the capabilities it needs as entries in its token's groups. Adding a new media-handling service does not require modifying every media file's DACL — the DACL already grants media-library-read; the new service just needs the capability on its token.
The administrative model becomes: services consume capabilities; resources grant capabilities; the directory maintains which services have which capabilities. ACLs on shared resources are stable. Adding a service is a token-policy change, not a DACL-rewriting exercise.
This is what positive confinement is for. Most non-trivial Peios deployments use it. Dedicated service accounts continue to exist for cases where the per-service identity is genuinely meaningful (a service whose data should be exclusively its own), but for shared resources reached by multiple services, capability-style positive grants are the canonical pattern.
Positive confinement does not bypass confinement #
This is the most important rule about composition and it is worth saying directly: positive confinement does not bypass confinement. If a token is confined, putting a capability SID in the token's groups does not exempt the resulting access from the confinement pass at step 11. The DACL walk at step 8 may grant rights through the capability appearing in groups, but the confinement intersection still runs, and if the confinement identity (confinement_sid plus confinement_capabilities) does not match the same ACE, the grant is dropped.
The kernel does not look at the SID and reason about its "intent". It runs each pipeline step with the inputs that step uses. Step 8 uses the token's groups. Step 11 uses the token's confinement_capabilities. A capability SID present in one but not the other satisfies one step but not the other, and an intersection that fails at any active step removes the rights.
Practically: on a confined token, positive confinement and standard confinement are complementary, not alternatives. To reach an object via a capability, both halves need to be in place:
- The capability SID in
groups, so the normal DACL walk at step 8 grants access. - The capability SID also in
confinement_capabilities, so the confinement intersection at step 11 preserves the grant.
Either half on its own grants the confined token nothing. Putting the capability only in groups produces a step-8 grant that step 11 strips. Putting it only in confinement_capabilities keeps step 11 happy but step 8 grants nothing for the intersection to preserve.
The pattern, then, on a confined service's token: the same capability SID appears in both places. The DACL grants to the capability; the token bears it twice; both checks pass.
For tokens that are not confined — which is many of them, especially in standalone or smaller deployments — only the groups half matters. The confinement pass is a no-op for an unconfined token; the normal DACL walk is the only check that runs against the capability. This is the "positive confinement without confinement" case: the capability SIDs are used as ordinary groups for access management, and the confinement layer never enters the picture.
What this is not #
A few clarifications, because the name encourages misreading:
- It is not a bypass of confinement. Placing a capability SID in a confined token's
groupsdoes not exempt the resulting access from step 11. The confinement pass still fires; the grant from the DACL walk is still subject to the intersection. See "Positive confinement does not bypass confinement" above. - It is not a separate access-check pass. There is no "positive confinement evaluator" in the kernel. The DACL walk at step 8 finds the capability SID like it finds any other group SID. No new mechanism, no new code path, no special handling.
- It is not the same as confinement. A token with capability SIDs only in groups (no
confinement_sid) is not confined. The confinement pass is a no-op. The token has the broad access its identity grants; the capabilities sit alongside as additional group memberships. - It is not opt-in for the resource. A resource whose DACL grants access to a capability does not need to know whether the caller is using the capability positively or as a confinement entry. The DACL says "this SID gets these rights"; whoever has the SID in a relevant field gets the access.
- It is not a kernel feature you can disable. Because there is no feature flag — only a convention about where you put capability SIDs on tokens — there is nothing to turn off. Sites that prefer dedicated service accounts simply do not use capability SIDs in groups.
A worked deployment #
A Peios machine runs three services that all read the user's music library: jellyfin, mpd, and a future streaming-bridge. The administrative pattern is positive confinement.
The DACL on /data/media/music/ (and every file under it):
ACCESS_ALLOWED SYSTEM GENERIC_ALLACCESS_ALLOWED BUILTIN\Administrators GENERIC_ALLACCESS_ALLOWED music-library-read GENERIC_READ(the capability SID, derived from "music-library-read" via SHA-256)
The directory policy in authd configures each service's token to include the music-library-read capability SID in its groups:
jellyfin's token:groups = [jellyfin_user_SID, music-library-read, network-server, ...]mpd's token:groups = [mpd_user_SID, music-library-read, audio-output, ...]streaming-bridge's token:groups = [streaming-bridge_user_SID, music-library-read, network-server, ...]
Each service is a different user identity. Each service has the capability through its groups. Each service can read the music library.
The administrator adds a fourth music-handling service. The new service's token policy includes music-library-read in groups. The DACL on /data/media/music/ is not touched. The service starts reading the library immediately.
If the deployment additionally confines the services for sandboxing, the same capability SID also appears in each service's confinement_capabilities. The DACL ACE for music-library-read is unchanged. The token now has the SID in two places, and both passes find it. Access still works.
This is the canonical scale pattern. Capabilities are the vocabulary of access; tokens consume capabilities; resources grant capabilities; administrative policy decides who gets what. No DACL rewrites when new services arrive; no proliferation of per-service users in every shared-resource ACL.
Where to go next #
For the intersection that still applies when a confined token carries capabilities in groups, read The confinement pass.
For the ordinary DACL walk that consumes capability SIDs placed in groups, read DACL evaluation.
Process integrity protection
Peios / Peios Security Fundamentals / Process Integrity Protection
Process integrity protection — PIP — is the access-control layer that gates one process's ability to operate on another. Where the DACL and tokens decide "is this principal allowed?", PIP decides "is the calling binary trusted enough?". A PIP-protected process — peinit, authd, the kernel's own helpers — cannot be debugged, signalled, suspended, or read from by code running at lower trust. Even root cannot.
The protection is rooted in the binary's signature. At exec, the kernel verifies the new program against its public-key catalog and assigns the resulting process a two-dimensional trust label — pip_type and pip_trust — that records what kind of trust the binary established and at what level. From then on, every cross-process operation against this process compares the caller's label to this one.
This is not the same axis as identity. Two processes running as the same user can have different PIP labels because they are running different binaries. A user-mode process and a TCB daemon running as SYSTEM are at different PIP levels; the user-mode process cannot interfere with the daemon even though both run as the same principal. The barrier is not who, it is what.
Where PIP lives #
Each process has a Process Security Block — the PSB — attached by the kernel. The PSB carries:
| Field | Meaning |
|---|---|
pip_type | The PIP type. One of None, Protected, Isolated. |
pip_trust | The PIP trust level. A numeric tier within the type. |
security_descriptor | The process SD — see The process security descriptor. |
| Mitigation flags | The process's enabled security mitigations, including NO_CHILD (whether the process may fork) — see Process mitigations. |
The PSB is not the token. The token says who the process is acting as; the PSB says what kind of process it is. The two have different lifecycles, different fields, and are read by different parts of the access pipeline. A thread impersonating a user changes its effective token but not its process's PSB. The PSB is fixed once the binary execs.
The 2D trust model #
PIP's trust is encoded as two numbers, deliberately not collapsed into a single ordering:
| Axis | Range | What it means |
|---|---|---|
pip_type | 0 (None), 512 (Protected), 1024 (Isolated) | The kind of trust. None = unprotected; Protected = the standard PIP-protected process; Isolated = reserved for future use. |
pip_trust | numeric (0, 1024, 1536, 2048, 4096, 8192 in v0.20) | The tier of trust within a type. Higher numbers are more trusted within the same type. |
The 2D model exists because trust is not a single line. A signed application from a third-party developer is trusted for what it is — its publisher attested to it — but is not at the same trust as a Peios TCB binary that has been signed by the OS itself. They have different types of trust, and within each type, there can be tiers.
In v0.20 the catalog is small — the full list of S-1-19-T-L label SIDs is in Well-known SIDs. The shape of the ladder, by example:
| SID | type | trust | What it represents |
|---|---|---|---|
S-1-19-0-0 | None (0) | 0 | Unprotected. Default for unsigned processes. |
S-1-19-512-2048 | Protected (512) | 2048 | Peios-distributed applications. |
S-1-19-512-8192 | Protected (512) | 8192 | Peios TCB (peinit, authd, loregd, lpsd, eventd). |
The Isolated type (1024) exists for a future where some processes need to be protected from even the rest of the TCB. In v0.20 it is documented for forward compatibility; no binary will receive this type.
Most processes on a running system have pip_type = None. PIP protection is opt-in via signing — most user-mode applications run unsigned and unprotected. The PIP-protected processes are the kernel's specifically-signed daemons.
How PIP gets assigned #
The kernel sets pip_type and pip_trust at exec time and never changes them afterwards:
- The kernel reads the binary about to be exec'd.
- It looks for a signature — an ELF
.peios.sigsection or asecurity.peios.sigxattr. - It verifies the signature against its compiled-in public key catalog.
- On a valid signature, the PIP fields are set to whatever level the catalog says the signing key represents.
- On no signature, invalid signature, or any verification failure,
pip_typeis set to None (0) andpip_trustto 0. The exec still succeeds — bad signatures do not block execution, they only fail to grant PIP protection.
Once exec completes, the fields are immutable for the lifetime of that process. fork() inherits the parent's PIP fields. exec() recomputes them from the new binary.
The signing and verification mechanics — what the signature format is, how the catalog works, why bad signatures do not block exec — are in Binary signing.
What PIP gates #
PIP fires on every cross-process operation that requires the caller to inspect or affect another process:
- Signals (kill, signal delivery).
- ptrace and its kin (memory read/write, attach, single-step).
- pidfd_open, pidfd_getfd, pidfd_send_signal.
- Reading or writing
/proc/<pid>/*for processes other than oneself. - Opening another process's tokens (
kacs_open_process_token,kacs_open_thread_token). - Setting process attributes (priority, affinity, rlimits).
- Profiling another process (
perf_event_open).
In every case, the kernel runs the two-check rule: the calling process must pass both an SD check (the target's process SD must grant the requested right to the caller's token) and a PIP dominance check (the caller's PIP label must dominate the target's). Both must succeed. Neither alone is enough.
The full mechanism is in The two-check rule. What matters for the overview: PIP does not replace identity-based access control, it sits alongside it. A PIP-protected process can still be administered by an SD that grants the rights; the SD just is not the whole story.
What PIP does not do #
A few clarifications that come up:
- PIP is not encryption. A PIP-protected process's memory is in plaintext. The kernel does not encrypt it or seal it; it just refuses ordinary access channels to it. A compromised kernel or a hardware-DMA attack bypasses PIP entirely.
- PIP is not capability isolation. Two PIP-protected processes at the same level can interfere with each other freely. The level is the boundary, not the identity.
- PIP is not a sandbox. A PIP-protected process is not contained — it is protected from containment-bypassing code. PIP protects high-trust processes from low-trust ones, not the other way around.
- PIP is not user-configurable. There is no API to upgrade a process's PIP level at runtime. The signature catalog is in the kernel; only re-execing a different binary changes the level.
- PIP does not require the binary to be running as a particular user. A user-mode app run by an administrator and signed at TCB level would still be a TCB-level process from PIP's perspective. The signature is what matters, not who started it.
The cleanest mental model: PIP is "what kind of program is this, and who is allowed to mess with it?". The token is "who is this program acting as, and what can they reach?". Each answers a different question. Both run.
Where to start #
If you want the process SD specifically — the SD that lives on the PSB and gates operations on the process — read The process security descriptor.
If you want the dominance comparison and the two-check rule — the rule that every cross-process operation runs both an SD check and a PIP dominance check — read The two-check rule.
If you want the edge cases — how PIP interacts with impersonation, how it differs from MIC, why peinit ends up being the lifecycle manager for PIP-protected processes, and what the model does not cover — read PIP in practice.
The process security descriptor
Peios / Peios Security Fundamentals / Process Integrity Protection
A process is an object the kernel protects, like any other. It carries a security descriptor — the process SD — that defines who can do what to this process. Where the token's SD controls who can do what to the token (read, duplicate, impersonate), the process SD controls who can act on the running process itself: signal it, debug it, inspect its memory, change its priority, open its tokens.
The process SD sits on the PSB, not the token. The token's SD is a separate field, governing token operations. The two are distinct objects with distinct policies. A change to one does not affect the other.
This page covers what the process SD contains, the access rights it uses, how it is created, and how it is modified. The dominance pairing — how the process SD plus PIP forms the two-check rule — is in The two-check rule.
What the process SD contains #
The process SD has the same shape as any other SD — owner, primary group, DACL, optional SACL. The fields mean the same things they mean elsewhere (see Security descriptors). What differs is the access rights the DACL grants.
The DACL on a process SD uses the process access rights — a set of 16-bit object-specific rights tailored to what one might do to a process. The full catalog with bit values is in Access mask bits; in summary:
| Right | What it gates |
|---|---|
PROCESS_TERMINATE | Send terminating signals (SIGKILL, SIGTERM, SIGABRT, etc.). |
PROCESS_SIGNAL | Send non-terminating informational signals (SIGCHLD, SIGURG, SIGWINCH, etc.). |
PROCESS_VM_READ | Read the process's memory. Required by ptrace(PTRACE_PEEK*), process_vm_readv, /proc/<pid>/mem reads. |
PROCESS_VM_WRITE | Write the process's memory. Required by ptrace(PTRACE_POKE*, PTRACE_ATTACH), process_vm_writev, /proc/<pid>/mem writes. |
PROCESS_DUP_HANDLE | Duplicate file descriptors out of the process via pidfd_getfd. |
PROCESS_SET_INFORMATION | Change process attributes — scheduling priority, CPU affinity, rlimits, certain /proc/<pid>/* writes. |
PROCESS_QUERY_INFORMATION | Read detailed process information — token, full /proc/<pid>/* reads (cmdline, status, io, limits, sched, mounts). |
PROCESS_SUSPEND_RESUME | Send stop/continue signals (SIGSTOP, SIGCONT). |
PROCESS_QUERY_LIMITED | Read limited process information (PID, image name, basic state). Required by pidfd_open. |
Plus the standard rights — DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE — and the special rights ACCESS_SYSTEM_SECURITY and MAXIMUM_ALLOWED. These mean the same things on a process as on any other object.
PROCESS_TERMINATE and PROCESS_SIGNAL are split because terminating signals are operationally different from informational ones. A monitoring tool might be allowed to send SIGCHLD-style signals without being allowed to kill the process. This is the kind of decomposition the process rights catalog makes possible.
Generic mapping #
The standard generic rights (GENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE, GENERIC_ALL) map to combinations of process-specific rights via the process GenericMapping table, cataloged in Access mask bits.
A DACL ACE that grants GENERIC_READ to some principal effectively grants them the right to read the process's memory, query its detailed information, and read the SD itself. A DACL granting GENERIC_EXECUTE lets them terminate or suspend the process. These are the abstractions tools use when they say "grant read access to this process" without enumerating every specific right.
How the process SD is created #
A new process's SD is set up at process creation. The path:
- At fork, the child inherits a copy of the parent's process SD. The child's process is a different object from the parent's, but the SD is structurally the same.
- At exec, if the new binary's signing changes the PIP fields significantly (in particular, if the new PIP type is non-zero where the old was zero, or if a
KACS_IOC_INSTALLof a different user-identity token happens between fork and exec), the kernel may regenerate the process SD from a default template based on the new token's identity. The intent is that a process running a TCB binary should have a TCB-flavoured process SD, not whatever its parent had. - The default template, when used, grants the process's user identity
PROCESS_QUERY_LIMITEDandPROCESS_QUERY_INFORMATION, grantsBUILTIN\AdministratorsandSYSTEMthe right to inspect and signal, and denies everything else to ordinary callers.
For typical user processes, the SD that ends up on the PSB is the inherited copy of the parent's. For TCB processes — the daemons signed at TCB trust level — the SD is regenerated to reflect the role of the process.
How the process SD is modified #
The process SD on a running process can be updated via kacs_set_sd on the PSB, just like setting an SD on a file. The caller needs WRITE_DAC on the target process (for the DACL) and the appropriate rights for owner, group, and SACL changes. The standard SD modification rules from Security descriptors apply.
A common use case: a service whose SD needs to be tightened after startup. The service launches with a default SD that lets the supervisor inspect and signal it; once the service is in steady state, it tightens its own SD to remove some of those rights, restricting which external callers can affect it.
Another use case: a process voluntarily allowing another to debug it. Adding an ACE granting PROCESS_VM_READ | PROCESS_VM_WRITE to a specific helper SID lets that helper attach a debugger without administrator intervention.
The process SD does not affect PIP. Changing the SD does not change the PIP fields. The two are independent — the SD-on-PSB gates "who is allowed" while PIP gates "what trust level is allowed". A debugger added to the SD still needs to PIP-dominate the target to actually debug it.
What the process SD does not do #
A few clarifications:
- The process SD does not gate the process's own access to objects. It controls operations targeting the process. What the process itself can do is governed by its token, not by its own SD. A process can be
PROCESS_TERMINATE-denyed by its own SD (other processes can't kill it) while still being free to do whatever its token allows. - The process SD does not replace PIP. A non-PIP-protected process can have a restrictive SD; an inspector granted
PROCESS_VM_READby the SD can still read its memory if PIP permits. A PIP-protected process needs both the SD grant AND PIP dominance — see The two-check rule. - The process SD does not control file access. Each open file has its own SD; the process's SD is unrelated to what files the process can open.
- The process SD does not control token operations. The token has its own SD; opening or duplicating tokens goes through that, not the process SD (although the token operations also check process-level rights as a wrapper —
kacs_open_process_tokenrequiresPROCESS_QUERY_INFORMATIONon the process AND the appropriate right on the token).
The cleanest mental model: the process SD answers "may I (the caller) signal, debug, or inspect that process?". Everything else about the process's behaviour, identity, and authority lives in other SDs or in the token.
Where to go next #
For how the process SD pairs with PIP dominance on every cross-process operation, read The two-check rule.
For the general SD model these fields come from — owner, DACL, SACL, and the modification rules — read Security descriptors.
The two-check rule
Peios / Peios Security Fundamentals / Process Integrity Protection
When one process wants to act on another — to signal it, debug it, read its memory, change its priority, or open its tokens — the kernel runs two checks. The SD check asks "is the caller allowed by the target's process SD?". The PIP dominance check asks "is the caller's PIP label at least the target's?". The operation proceeds only if both checks pass. Either one failing is enough to deny.
The two-check rule is the rule that gives PIP its bite. Without it, an administrator's token would let any administrator-run code reach into a TCB daemon and read its memory; with it, the calling binary's trust level becomes a separate gate that no identity, no group, no privilege can bypass.
This page covers the rule itself: what each check looks at, how the dominance comparison works, what privileges can and cannot bypass, and how specific kernel operations map onto the rule.
The two checks, side by side #
flowchart LR
A["Cross-process operation"] --> B["SD check: process SD vs caller's token"]
A --> C["PIP check: caller's PSB vs target's PSB"]
B -->|grants the required right| D["AND"]
B -->|denies| F["Operation denied"]
C -->|caller dominates target| D
C -->|caller does not dominate| F
D --> E["Operation allowed"]
Both checks run at the entry point of the operation. They are not sequenced — there is no "first the SD check, then PIP". The kernel reads both inputs and computes both results; the operation proceeds only on both passing.
| Check | Reads | Compares | Decides |
|---|---|---|---|
| SD check | Target's process SD; caller's effective token | Standard AccessCheck pipeline against the SD's DACL | Which process rights the caller has on the target. |
| PIP dominance | Caller's PSB (pip_type, pip_trust); target's PSB | All-or-nothing comparison: caller's type ≥ target's type AND caller's trust ≥ target's trust | Whether the caller is trusted enough to act on the target. |
Each check is independent. A caller can pass one and fail the other. The operation result is pass_SD AND pass_PIP.
The dominance comparison #
PIP dominance is two-axis, conjunctive. The caller's PSB carries pip_type and pip_trust. The target's PSB carries the same fields. The caller dominates the target if and only if:
caller.pip_type >= target.pip_type AND
caller.pip_trust >= target.pip_trust
Both inequalities must hold. Both holding is dominance. Either failing is non-dominance.
A few non-dominance cases worth pinning:
- Same type, lower trust. A Protected/1024 caller does not dominate a Protected/8192 target. Same trust kind, but the target is more trusted.
- Higher trust, lower type. A None/8192 caller (which would be weird in v0.20 but legal in principle) does not dominate a Protected/0 target. The trust level is higher numerically, but the type is lower; both have to dominate independently.
- Different types, neither higher. A Protected/4096 caller does not dominate an Isolated/0 target. Protected (512) is less than Isolated (1024), so the type comparison fails. Even though the trust number is higher, the type fails the AND.
The comparison is not ordered. There is no "single PIP score" that says "you are more trusted than that other process". A caller can be more trusted on one axis and less on another, and the dominance check declines to compare further — without dominance on both axes, the operation is denied.
This two-axis structure is what lets the model distinguish "Authenticode-signed third-party app" from "Antimalware tool" — both are Protected type, but one's purpose is different from the other's, and they need not be ordered. The trust tier reflects the role; the type reflects the kind of authority.
The non-dominance default #
A caller with pip_type = None (the unsigned, default state) dominates only targets that also have pip_type = None. They cannot reach any Protected or Isolated target. Most user-mode processes are in this state — they can interfere with each other freely, but not with the TCB daemons.
A caller with pip_type = Protected dominates other Protected processes at the same or lower trust tier, and dominates None processes entirely. But not Isolated.
A caller with pip_type = Isolated (none in v0.20) dominates everything — Protected and None at any trust level, plus other Isolated processes at the same or lower trust.
The most common protective barrier is the None-to-Protected boundary. User-mode applications (pip_type = None) cannot signal, debug, inspect, or modify the running TCB processes (pip_type = Protected). This is the practical security boundary PIP creates.
What SeDebugPrivilege bypasses #
SeDebugPrivilege is the closest thing to an override for the SD check, but it does not override PIP.
The privilege, when present and enabled on the caller's token:
- Bypasses the SD check for cross-process operations. A caller holding the privilege does not need the target's SD to grant them the required process rights; the SD check is treated as passing regardless of what the DACL says.
- Does not bypass the PIP dominance check. A caller holding the privilege still must dominate the target's PIP. If they do not, the operation fails.
This is the most important rule about SeDebugPrivilege. The privilege gives the holder the ability to debug arbitrary user processes, but it does not give them the ability to reach into the TCB. A debugger with SeDebugPrivilege can attach to any user-mode process they can find; it cannot attach to a TCB daemon. The privilege handles identity-based gating; PIP handles trust-based gating, and the privilege has no power over the trust axis.
Tools that need to debug TCB processes need a different mechanism — typically running as a TCB binary themselves (i.e., a debugger signed at TCB level). There is no privilege that opens this door. The signature is the only path.
What other privileges do not bypass #
A few other privileges are worth noting:
SeTcbPrivilegedoes not bypass PIP either. The privilege grants a lot — token creation, mount-policy administration, central-policy distribution — but it operates within the PIP boundary. A TCB caller (holdingSeTcbPrivilege) is typically also a TCB-trust-level process anyway, so the dominance is satisfied by virtue of the binary, not the privilege.SeBackupPrivilegeandSeRestorePrivilegedo not bypass PIP. A backup tool granted access to read another process's memory via the SD check still needs to dominate PIP.SeImpersonatePrivilegedoes not bypass PIP. Impersonation changes the effective token; it does not change the PSB. A high-trust client's impersonation token does not promote the impersonating server's PIP level.
The pattern: no privilege in the catalog bypasses PIP dominance. PIP is the trust ceiling that operates above the privilege model. It is the answer to "what if a malicious administrator gets a privileged token?" — the answer is "they still cannot reach the TCB, because the TCB is at a higher PIP level than any binary they could run".
The operations the rule applies to #
The two-check rule fires on every cross-process operation. The full list of kernel surfaces it covers:
| Operation | Process right needed | Notes |
|---|---|---|
kill, signal delivery | PROCESS_TERMINATE, PROCESS_SUSPEND_RESUME, or PROCESS_SIGNAL (by signal class) | PIP dominance also required. Classified by default disposition — see Signals in detail for the full rules, the self-exemption, and the kernel-originated bypass. |
kill -STOP, kill -CONT | PROCESS_SUSPEND_RESUME | PIP dominance required. |
ptrace(PTRACE_ATTACH), ptrace(PTRACE_POKE*) | PROCESS_VM_WRITE | PIP dominance required. SeDebug bypasses the SD; not PIP. |
ptrace(PTRACE_PEEK*) | PROCESS_VM_READ | Same. |
PTRACE_TRACEME | PROCESS_VM_WRITE on caller's SD; PIP dominance of the nominated tracer over the calling process | Reverse-flavoured of the others. |
pidfd_open | PROCESS_QUERY_LIMITED | PIP dominance required. |
pidfd_getfd | PROCESS_DUP_HANDLE | PIP dominance required. |
process_vm_readv, process_vm_writev | PROCESS_VM_READ, PROCESS_VM_WRITE | Route through the ptrace check. |
/proc/<pid>/mem read/write | PROCESS_VM_READ / PROCESS_VM_WRITE | Same. |
/proc/<pid>/ basic reads (stat, comm, wchan) | PROCESS_QUERY_LIMITED | PIP dominance required. |
/proc/<pid>/ detailed reads (cmdline, status, io, limits, sched, mounts) | PROCESS_QUERY_INFORMATION | PIP dominance required. |
/proc/<pid>/ writes (sched, oom_adj, coredump_filter) | PROCESS_SET_INFORMATION | PIP dominance required. |
sched_setaffinity (cross-process) | PROCESS_SET_INFORMATION | PIP dominance + SeIncreaseBasePriorityPrivilege. |
setpgid (cross-process) | PROCESS_SET_INFORMATION | PIP dominance required. |
getpgid, getsid (cross-process) | PROCESS_QUERY_LIMITED | PIP dominance required. |
perf_event_open (target-specific) | PROCESS_QUERY_INFORMATION | PIP dominance required + SeProfileSingleProcessPrivilege. SeDebug bypasses SD only. |
capget(pid) (cross-process) | PROCESS_QUERY_INFORMATION | PIP dominance required. |
kacs_open_process_token | PROCESS_QUERY_INFORMATION on the process AND the rights on the token | PIP dominance required. |
kacs_open_thread_token | Same | PIP dominance required. |
In every case, two checks: process SD grants the right, and caller's PIP dominates target's. Failing either denies.
Same-process operations #
The two-check rule applies only to cross-process operations. When a thread acts on its own process — opening its own token, reading its own memory, signalling itself — neither check fires. A process can always operate on itself.
This is what makes the model usable. PIP is a boundary between processes, not a constraint on what a process can do internally. A TCB daemon can read its own memory, signal itself, modify its own token state without going through the two-check rule.
What happens on failure #
When the two-check rule denies an operation, the failure mode depends on the operation:
- Signal delivery returns
-EPERM. - ptrace returns
-EPERM(or-ESRCHin some no-trust-revealing paths). - pidfd_open,
pidfd_getfdreturn-EACCES. - /proc/
/ * reads return-EACCESor, in some cases, hide the directory entry entirely (the kernel does not reveal information about processes a caller cannot reach). - kacs_open_process_token returns
-EACCES.
The kernel deliberately does not always distinguish "you do not have the right" from "this process does not exist" — for some sensitive operations, returning the same error in both cases prevents a low-trust caller from learning about the presence of high-trust processes.
Signals in detail #
Signal delivery is the two-check operation with the most edge cases, so the rules deserve spelling out.
Which right a signal needs is decided by its default disposition. Signals whose default action terminates the target — SIGTERM and SIGKILL, but also SIGHUP, SIGINT, SIGUSR1/2, SIGPIPE, and every realtime signal — require PROCESS_TERMINATE. The job-control set (SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU, SIGCONT) requires PROCESS_SUSPEND_RESUME. Signals ignored by default (SIGCHLD, SIGURG, SIGWINCH) require PROCESS_SIGNAL. The kill(pid, 0) existence probe delivers nothing and requires PROCESS_QUERY_LIMITED — it is a query, and it is treated as one.
Self-signaling is exempt, structurally. A thread signaling its own process — raise(), abort(), pthread_kill() to a sibling thread — never runs either check. This does not rely on the default process SD granting the process access to itself: it holds even for restricted or confined tokens that would fail an access check against their own SD. A sandboxed process can always abort itself.
Kernel-originated signals bypass the checks entirely — there is no sending process to check. This covers hardware faults (SIGSEGV, SIGBUS), kernel notifications (SIGCHLD on child exit, SIGPIPE on broken pipe), and — the case worth pausing on — terminal-generated job control. When you press Ctrl-C, the tty driver signals the foreground process group; no permission check runs, and the interrupt reaches the foreground processes even if they are more privileged or more PIP-trusted than you. This is deliberate: authorization for keyboard signals is possession of the controlling terminal (gated by the terminal's file SD when it was opened), and a privileged process that attaches to your terminal has chosen to take input from you. Note the limit: this path only carries the terminal's own signals — nobody can use it to deliver an arbitrary kill().
Group kills are checked per target. kill(-pgid, ...), kill(0, ...), and kill(-1, ...) evaluate each candidate process independently, deliver to the subset the caller may signal, and succeed if at least one delivery happened. A mixed-privilege process group (an elevated member in your pipeline) gets partial delivery — the same behavior Linux exhibits for mixed-uid groups.
There is no same-session SIGCONT exception. POSIX carves out "any process may SIGCONT members of its own session"; Peios does not implement it — SIGCONT needs PROCESS_SUSPEND_RESUME like the rest of the job-control set. In practice the default process SD's user-SID entry covers resuming your own stopped jobs; the divergence only surfaces when resuming a same-session process whose identity no longer matches yours.
si_pid and si_uid are informational. The sender identity carried in siginfo_t is the sender's projected UID captured at send time, plus a PID that may have been recycled by the time you read it. Like SO_PEERCRED, these are for logging and display, never for authorization.
Why both checks exist #
It is reasonable to ask: why not collapse the two checks into one? Either the SD knows about the trust level, or PIP knows about the SD; pick one.
The answer is that the two checks model different things and have different administrators:
- The SD says "who, by identity, can act on this process". An object owner adjusts this DACL like any other. The rights are operationally meaningful (signal vs read vs debug) and are the natural unit for fine-grained access control.
- PIP says "what kind of binary can act on this process". The kernel sets this based on the binary's signature; no administrator can adjust it directly.
If you only had the SD, every binary that ran with the right token could reach any process. Privilege isolation would not exist; an administrator's debugger could attach to the TCB. Conversely, if you only had PIP, fine-grained per-process access control would be impossible; the TCB would be all-or-nothing.
The two together give you both: identity-based control with PIP as a hard ceiling above it. The combination is the security model.
Where to go next #
For the practical consequences of the rule — the impersonation asymmetry, peinit as lifecycle manager, and the v0.20 limitations — read PIP in practice.
For where PIP's checks sit in the wider AccessCheck pipeline, read Access decisions.
PIP in practice
Peios / Peios Security Fundamentals / Process Integrity Protection
The PIP model is small once you have the dominance rule and the two-check rule in hand. But the practical consequences of those two rules ripple outward in ways that are not obvious from the rules themselves. PIP's asymmetric relationship with impersonation; the operational pattern that ends up forcing peinit to be the lifecycle manager for every PIP-protected process; the contrast with MIC; the threat-model ceiling above which PIP simply does not protect; and the v0.20 limitations of the implementation.
This page covers each.
PIP and impersonation #
PIP reads the PSB, not the effective token. Impersonation changes the effective token; it does not change the PSB. The consequence: impersonating a high-trust identity does not raise a process's PIP level.
A worked example. A user-mode application — pip_type = None — accepts a connection from a TCB daemon. The TCB daemon connected at Delegation level. The application calls kacs_impersonate_peer and now has the TCB daemon's identity installed as its effective token. The token is at SYSTEM integrity, with TCB-flavoured group memberships, with many privileges enabled.
What can the application now do?
- Access checks that use the effective token (file access, registry access, regular DACL evaluation) succeed with the TCB daemon's authority. The application can open TCB-only files.
- Access checks that use the PSB (PIP dominance for cross-process operations) still see the application's None-level PSB. The application cannot signal or debug TCB daemons — its PIP did not change.
The asymmetry is deliberate. PIP is a property of the binary running, not of who the binary is acting as. Allowing impersonation to raise PIP would let any service that ever accepts a TCB-level client effectively become TCB-level for the duration. The model is built on the idea that the binary is what is trusted, and a binary cannot lend its trust through impersonation.
The same asymmetry applies in reverse: if a TCB daemon impersonates a user-mode client (rare; usually it just acts as them at the file-access level), the TCB daemon's PSB is still TCB-level. The impersonation does not lower its PIP.
MIC vs PIP #
PIP and MIC look superficially similar — both compare a numeric level on the caller against a level on the object, both block when the caller is below. The two diverge in several ways that matter.
| MIC | PIP | |
|---|---|---|
| What it labels | Tokens (integrity_level) | Processes (pip_type, pip_trust) on the PSB |
| What it reads at access time | Effective token (impersonation-visible) | PSB (impersonation-invisible) |
| Axes | 1 (integrity level) | 2 (type and trust) |
| Default | Medium with NO_WRITE_UP for unlabelled objects | None — objects are PIP-unrestricted unless they opt in via a label ACE |
| Bypassed by privileges? | Privilege-granted bits survive | No — privilege-granted bits are revoked for non-dominant callers |
| Default policy | Block writes from below | Only the rights in the trust-label ACE's mask are permitted; everything else is denied |
The two layers complement each other. MIC is the integrity axis for token-driven access (where impersonation matters); PIP is the trust axis for process-driven access (where the binary matters). An access can be blocked by either, or allowed by both.
The most consequential difference is the privilege handling. MIC respects privilege grants; PIP does not. A backup tool with SeBackup granted to it can read across MIC boundaries (read a higher-integrity file) because privileges preserve bits through MIC. The same backup tool cannot read across PIP boundaries — SeBackup does not grant the read on a PIP-protected object if the caller does not dominate. The privilege grant is stripped at the PIP check.
This is the security model's load-bearing assertion: privileges are not enough. A privileged but untrusted binary cannot bypass PIP-protected objects.
peinit as the lifecycle manager #
A PIP-protected process can only be signalled by a process that PIP-dominates it. For the TCB daemons — Protected/8192 — that means the signalling process needs at least Protected/8192. Which means it needs to be a TCB-signed binary itself.
In practice, the only process that fits this description and exists for the express purpose of managing other processes is peinit. peinit is signed at TCB level, runs at PIP Protected/8192, and is the system's init-equivalent.
The consequence: every PIP-protected process's lifecycle — start, restart, shutdown — flows through peinit, because no other binary can send the relevant signals. An ordinary administrator cannot use systemctl stop authd (or its Peios equivalent) directly; they ask peinit to do it. Even SIGTERM from a shell would be denied if the shell is not PIP-dominating.
This is the lifecycle manager pattern. It is not enforced by a specific kernel mechanism (peinit is not "marked" as the lifecycle manager); it falls out naturally from the PIP rule. peinit happens to be the only thing that can talk to TCB processes.
Practical implications:
- Tooling that interacts with the TCB sends commands to peinit, not directly to the TCB processes.
- Restart loops are implemented in peinit. If a TCB daemon crashes, peinit is the entity that decides whether and how to restart it — no external supervisor can.
- Service-management tools for non-TCB services can be more direct, because non-TCB processes are not PIP-protected and ordinary signal-sending works.
Privileges and PIP, in detail #
The relationship between privileges and PIP comes up enough to be worth pinning. The general rule: privileges do not bypass PIP. The specific cases:
SeDebugPrivilege: bypasses the SD check on cross-process operations. Does not bypass PIP. A privileged debugger can attach to any user-mode process; it cannot attach to TCB processes.SeBackupPrivilege/SeRestorePrivilege: grants read/write bits through AccessCheck. Bits granted by these privileges are subject to PIP — a non-dominant caller has them stripped during the PIP check for PIP-protected objects. (The grant happens at step 4 of AccessCheck; the PIP strip happens at step 5.)SeImpersonatePrivilege: lets a service impersonate clients. Does not affect PIP; the PSB stays the same. The impersonated token may carry MIC at a higher level (capped by the integrity ceiling), but PIP does not move.SeTakeOwnershipPrivilege: grantsWRITE_OWNERregardless of DACL. Subject to PIP — a non-dominant caller cannot take ownership of a PIP-protected object even with the privilege.SeTcbPrivilege: gates TCB operations (token creation, mount-policy administration). Held only by TCB processes anyway, which are PIP-dominant by virtue of their signing. The privilege does not extend PIP authority.
In short: every privilege the catalog defines operates within PIP, not above it. There is no privilege that bypasses PIP. The trust ceiling is absolute from the privilege model's perspective.
The threat-model ceiling #
PIP's protection is bounded by what the kernel can enforce. Specifically:
- A compromised kernel voids PIP. If an attacker has code running in the kernel (e.g., via a malicious or buggy kernel module), they can read PSBs directly, modify memory directly, and bypass every PIP check. The defence against this is
CONFIG_MODULE_SIG_FORCE=y— only signed kernel modules are accepted — but that defence is itself a precondition for PIP being meaningful. - Hardware DMA bypasses PIP. A device that can perform DMA can read or write any physical memory. The defence is IOMMU configuration, which is a kernel concern, not a PIP concern.
/dev/memaccess bypasses PIP if writable. Even reading/dev/memgives access to physical memory. The defence isCONFIG_STRICT_DEVMEM=y, which restricts/dev/memto I/O regions only.- PIP does not provide cryptographic memory isolation. Two PIP-protected processes share the same physical memory and the same kernel. PIP is logical, not cryptographic; a kernel compromise sees through it.
These are not bugs in PIP — they are the kernel's responsibility, sitting above the PIP layer. PIP is the right defence for "a buggy user-mode application that an attacker is running"; it is not the right defence for "an attacker who has kernel code execution".
For a deployment where the threat model includes kernel-code attackers, PIP is insufficient on its own and must be paired with hypervisor-style isolation. That is a Peios-v2 concern, not a PIP concern.
What v0.20 does not do #
A handful of PIP limitations specific to v0.20 are worth knowing:
- No per-binary revocation. A signed binary later found malicious cannot be invalidated short of removing it from the filesystem or replacing the kernel's signing key catalog. There is no hash-based revocation list.
- The Isolated PIP type is reserved.
pip_type = 1024is documented and present in the model but no v0.20 signing key targets it. No binary will have this type until a future version defines its use. - Scripts are not signed. A script's PIP comes from its interpreter, not the script itself. A signed TCB interpreter running an untrusted script runs the untrusted code at TCB level. This is why interpreters are not signed at TCB unless the script content is itself trusted.
- Only one signing key in v0.20 (the TCB key). Lower-trust tiers (App, Authenticode) are reserved in the catalog but no key exists for them in v0.20. All signed binaries on a v0.20 system are TCB binaries. App-level and Authenticode-level signing is future work.
- PIP visibility in
/proc. PIDs and directory names remain visible in/procgetdents()listings regardless of PIP — the kernel reveals that processes exist, even when their data cannot be read. Only file access within/proc/<pid>/is gated. A future version may filter directory listings as well. - Coredumps are disabled for PIP-protected processes. A PIP-protected process's coredump is a potential secret leak; the kernel sets dumpability to false at exec and refuses to re-enable it via
prctl(PR_SET_DUMPABLE, 1)whilepip_type != None. The defence is mandatory.
These are limitations of the implementation, not the model. The model accommodates revocation, additional types, signed scripts, and richer visibility filtering; they just aren't there yet.
When PIP is the right tool #
A few patterns where PIP is the answer:
- Protecting kernel-adjacent daemons. authd, loregd, eventd, and other TCB components must not be reachable by ordinary user code, even by administrators. PIP is the layer that enforces this. Their signing at TCB level produces processes that no non-TCB caller can interfere with.
- Anti-malware tools that need to be hard to disable. A security daemon signed at the AntiMalware tier (
pip_type = Protected,pip_trust = 1536in the catalog) is protected against attempted disablement by malware running at lower trust. - Cryptographically signed third-party services that need protection from local administrators. A licensed-software service whose vendor has signed it at the App or Authenticode tier can be configured to refuse modification by anyone other than at the same or higher tier.
And patterns where PIP is not the answer:
- Sandboxing untrusted code. PIP protects high-trust processes from low-trust ones, not the other way around. To restrict what an untrusted process can do, use confinement or restricted tokens.
- Authorising users for specific operations. PIP is about binaries; identity-based authorisation is the DACL's job.
- Network access control. PIP does not gate network operations; nftables, the network stack, and the SDs on network endpoints do.
The cleanest mental check: ask "is the policy I want to express about what kind of program may do this?". If yes, PIP. If it is about who may do this, the DACL is the answer.
Where to go next #
For how a binary comes to carry a PIP level at all — signatures, verification at exec, and pinning — read Binary signing.
For the rule that gives PIP its bite on every cross-process operation, read The two-check rule.
Binary signing
Peios / Peios Security Fundamentals / Binary Signing
A signed binary carries a cryptographic signature that lets the kernel decide what trust level a process should run at when it execs that binary. The signature is the gate to process integrity protection — every PIP-protected process is PIP-protected because the binary it is running was signed at the appropriate trust level. There is no other way to acquire PIP. No syscall, no privileged operation, no runtime escalation. The signature is it.
The signing model is built on a single principle: signing only adds trust; it never blocks execution. An unsigned binary still runs — it just runs as pip_type = None, unprotected. A binary with a malformed or invalid signature also runs as None. The signature is the kernel's way of recognising "this binary has been blessed at this level by someone we trust"; its absence or invalidity is "we did not recognise this binary, so we will treat it as untrusted". That is different from "we are not going to let it run". Permissiveness is the rule that distinguishes signing from anti-virus.
What signing is for #
Signing serves one purpose: it lets the kernel assign a PIP type and trust level at exec. The kernel reads the binary, finds (or doesn't find) a signature, verifies it against its compiled-in public-key catalog, and sets the new process's pip_type and pip_trust accordingly. From that moment on, PIP enforcement uses those fields.
This is what PIP-protected processes need. peinit, authd, loregd, lpsd, eventd — the TCB daemons — are signed with the TCB key during image build. When the kernel execs them, it sets pip_type = Protected and pip_trust = 8192. Once running, no non-TCB caller can interfere with them because no non-TCB caller dominates their PIP label.
Without signing, the model has nowhere to anchor. There would be no way for the kernel to know that one binary is the real authd and another is malware impersonating it. The signature is the attestation that links a binary on disk to a trust level the kernel will assign at exec.
Where signing fits in the OS #
Signing is a kernel concern, not a userspace one. The kernel:
- Reads the signature blob from the binary.
- Verifies it against the compiled-in public-key catalog.
- Sets the
pip_typeandpip_trustfields on the new process's PSB.
Userspace tools — typically the image builder, peiso — produce the signature at build time. They have the private key, compute the hash, generate the signature blob, attach it to the binary, and ship it. After that, the private key must not be present on any running Peios system. Verification is public-key only; the only credential a running system needs is the public key, which is compiled into the kernel image.
The split between userspace signing and kernel verification means a Peios system can never sign new binaries itself. Distribution of new signed binaries happens through the package system, with signatures produced offline by whoever holds the relevant private key.
The permissiveness rule #
The kernel's behaviour when a signature is absent or invalid is the design decision that distinguishes signing from anti-virus.
| Condition at exec | Result |
|---|---|
| No signature at all | Process runs at pip_type = None, pip_trust = 0. |
| Signature present, version byte unrecognised | Same. |
| Signature present, key not in catalog | Same. |
| Signature present, but bytes do not verify (hash mismatch) | Same. |
| Signature present and verifies against a catalog key | Process runs at the PIP level the catalog entry specifies. |
The first four rows all produce the same outcome: the exec succeeds, the process runs at None. The fifth row produces a PIP-protected process. In no case does the exec fail because of signing.
The rationale: signing is the layer that adds trust to specific binaries. It is not the layer that decides "only these binaries may run". A user-mode application not signed at any level should still run; it should just not have PIP protection. The mechanism that decides what may run at all is mount policy (FACS), file permissions (DACL), and capabilities (KACS). Signing is a separate axis that sits on top.
This is why signing does not have a "policy" you configure to "require all binaries to be signed". The kernel does not enforce that. The signing layer itself is permissive.
The corollary is that mmap(PROT_EXEC) is not permissive — that is one of the Process mitigations (LSV, Library Signature Verification), and it enforces signing for libraries loaded into a PIP-protected process. But that is the mitigation, not the signing layer. Signing decides PIP at exec; LSV decides what libraries may then be loaded.
What signing does not do #
A few clarifications:
- Signing does not authenticate the user. A binary signed at TCB level run by an ordinary user is still a TCB-level process from PIP's perspective. The user is not the signer; the signer is whoever owned the private key when the binary was built. Identity-vs-trust is the two-axis story.
- Signing does not prevent execution. A binary that fails verification still runs. The signing layer does not gate exec; it gates PIP.
- Signing is not revocation. A previously-trusted binary later found to be malicious cannot be invalidated short of removing it from the filesystem or replacing the kernel's public-key catalog. There is no hash-based revocation list in v0.20.
- Signing does not verify provenance. The signature confirms that whoever held the corresponding private key produced this exact bytes-on-disk. It does not confirm where the binary came from, who built it, or whether it does what its name suggests.
- Signing does not handle scripts. A script is not a signed binary; the script's PIP comes from the interpreter's PIP. See Verification and pinning.
What the user sees #
For most users, signing is invisible. A binary built into the Peios image — peinit, authd, the rest — is signed by peiso during build, and the user never sees the process. When they exec these binaries (or peinit execs them on the user's behalf), the kernel verifies the signature and sets PIP, and the binary runs as expected.
For binaries distributed through the package system, the package's .peipkg is itself signed at the package layer (different from the binary-signing layer this topic covers), and the binary inside it may or may not carry a Peios-format signature for PIP purposes. In v0.20, only TCB binaries do. Future versions may add Authenticode-style App and third-party tiers.
For binaries the user produces — a compiled program, a self-built tool — there is no signature, exec runs them at pip_type = None, and they get no PIP protection. They are still bound by all the other access control layers; they just are not high-trust.
Where to start #
If you want the on-disk format — what the signature blob actually looks like, where it lives in an ELF binary, how non-ELF files carry it — read Signature format.
If you want the kernel's behaviour at exec — how verification proceeds, the stable-snapshot rule, what content pinning does after a binary is verified, and how scripts and interpreters interact — read Verification and pinning.
If you want the key management story — where the TCB key lives, how peiso uses the private key at build time, the constraints on key handling — read Keys and image build.
Signature format
Peios / Peios Security Fundamentals / Binary Signing
The signature blob is the same shape regardless of where it sits — 3310 bytes, structured as a version byte followed by an ML-DSA-65 signature. What changes is where the blob lives and what bytes it covers. ELF binaries hold the blob in a dedicated section; non-ELF executables hold it in an extended attribute or alongside the file. The hash being signed is computed differently for each.
This page covers the blob's structure, where it can live, and how the content hash is computed for each placement.
The signature blob #
Every Peios signature is exactly 3310 bytes:
| Bytes | Field | Meaning |
|---|---|---|
| 0 | Version | 0x01. The only version supported in v0.20; any other value is rejected. |
| 1–3309 | ML-DSA-65 signature | A 3309-byte raw ML-DSA-65 signature. The thing the kernel verifies. |
The version byte is what gives the kernel room to evolve the format. A signature with version 0x02 (for example) would be treated as unrecognised in v0.20 — the kernel does not panic, it just declines to verify the signature, and the process runs at pip_type = None. Future versions may define new layouts; current versions will see them as if they were absent.
The signature is computed over a 32-byte content hash (covered below) using pure ML-DSA — FIPS 204's ML-DSA.Sign — and not HashML-DSA. The pre-hash variant HashML-DSA is for cases where the signer hashes the message first; here the signer treats the 32-byte hash as the message and signs it directly. There is no double-hashing.
The FIPS 204 context string is always empty. Signers must not set one, and a signature produced with a non-empty context will not verify. This matters if you are building your own signing tool: most ML-DSA libraries accept an optional context parameter, and leaving it at its default (empty) is what Peios expects.
The 3309-byte signature is the raw ML-DSA-65 signature encoding — no ASN.1 wrapper, no envelope, no metadata. The kernel parses it as raw bytes and feeds them straight into the verification routine.
ML-DSA is a post-quantum signature scheme, so a signature is considerably larger than the elliptic-curve signatures you may be used to. That size is why the blob is 3310 bytes rather than the 65 an Ed25519 design would need, and it is the reason the parameter set is ML-DSA-65 rather than the larger ML-DSA-87 — see Keys and image build.
Where the blob lives #
A signature blob can live in three places, depending on the file:
| Placement | When used |
|---|---|
ELF section .peios.sig | ELF binaries. The signature lives inside the file. |
security.peios.sig xattr | Non-ELF binaries. Also a fallback for ELF binaries without a .peios.sig section. |
Detached .sig file | Used during image build for non-ELF executables. The image builder reads the detached file and stamps the xattr. |
The order matters. For ELF binaries the kernel looks for the .peios.sig section first; if found, the xattr is not consulted as a fallback. The ELF section is the canonical location.
For non-ELF binaries — scripts (which are themselves not signed; see Verification and pinning), data files used by tools, anything that does not begin with the ELF magic — only the xattr is consulted.
Detached .sig files are a transient form. They exist on the file system being assembled by peiso (the image builder) so that the builder can stamp the xattr from them. By the time the image boots, detached files are not used; the xattrs are what the kernel reads.
The ELF .peios.sig section #
For ELF binaries, the signature lives in a section of the binary's own ELF structure:
| Attribute | Value |
|---|---|
| Section name | .peios.sig |
| Section type | SHT_PROGBITS (typical ELF section type, holds raw data) |
| Section size | Exactly 3310 bytes |
The section is part of the ELF file. It travels with the binary through every operation a normal ELF travels through: filesystem copy, network transfer, package archive, image-build, anything. As long as the ELF is intact, the signature is part of it.
This is why the ELF section is preferred for ELF binaries. An xattr is filesystem metadata; it can be lost in a copy that does not preserve xattrs (a cp without --preserve=xattr, an archiver that does not understand the namespace, a network transfer that strips them). An ELF section is part of the file itself; nothing short of editing the ELF strips it.
The security.peios.sig xattr #
For non-ELF files, the same 3310-byte blob lives in an extended attribute:
| Attribute | Value |
|---|---|
| xattr name | security.peios.sig |
| xattr value | The 3310-byte blob, identical to what an ELF section would hold |
The security.* namespace requires privileged access to set — ordinary writes do not propagate to the xattr, which is the security boundary. The image builder sets the xattr at build time using its privileged access; the kernel reads it at exec.
Loss of the xattr (a copy without xattr preservation, a backup-restore through a tool that ignores security.*) results in the file being treated as unsigned. The signature blob can be re-applied if the corresponding .sig file is preserved, but the xattr layer is not the most robust place for it. ELF binaries get more durability by virtue of their structural signature; non-ELF binaries depend on xattr-preserving operations.
Detached .sig files #
During image build, peiso accepts detached signature files alongside the executables they cover. The convention is <binary>.sig — a 3310-byte file containing exactly the blob that should be stamped as the xattr.
The detached form has no role in a running system. It exists only as a packaging convention so that the build system can keep signatures next to their files without modifying the files themselves. peiso reads the .sig files, validates them, stamps the xattrs, and discards the detached forms.
The content hash #
The signature is over a 32-byte SHA-256 hash of the file's content. The bytes that go into the hash differ between ELF and non-ELF files because of how the signature is placed.
ELF content hash #
For ELF binaries, the hash is computed over the binary with the .peios.sig section's contents zeroed. The section header entry — the entry in the section header table that describes the .peios.sig section — is preserved. Only the section's 3310 bytes of payload are replaced with zeros for the hash computation.
This rule lets the section travel with the file without the signature signing itself. If the hash were computed over the binary including the signature, the signer would face a chicken-and-egg problem: sign first to know what to put in the section, but the section's contents change the hash. By zeroing the section contents during the hash, the section's position and size are part of the hash (via the section header) but its contents are not.
Verification follows the same rule: the kernel zeros the section contents (in its own working copy, not on disk), computes the hash, and verifies the signature against that hash. If anything else in the binary changed, the hash differs and verification fails.
Non-ELF content hash #
For non-ELF files, the hash is simpler: SHA-256 of the entire file, byte-for-byte, no exclusions.
The signature for a non-ELF file does not live in the file, so there is no need to exclude any region from the hash. The xattr holds the signature, the file's bytes are unmodified, and the hash covers all of them.
Why a section header found means no xattr fallback #
A subtle rule worth knowing: if an ELF binary has a .peios.sig section header entry (even an empty or malformed one), the kernel uses that path and does not fall back to the xattr.
This is to prevent confusion. A binary that appears to be ELF-signed but has a corrupt section gives the kernel a deterministic answer: unsigned. The xattr is not consulted as a second chance. The reasoning is to keep the verification rule simple — once the kernel has decided "this is an ELF binary with a signing section", that is the one and only place it looks. A binary that has both a (broken) section and a (working) xattr is also unsigned; the section's existence shadows the xattr.
The implication for someone signing binaries: choose one path per binary. ELF binaries should have a .peios.sig section or an xattr, not both. Mixed cases are valid but the section wins, and a broken section makes the binary unsigned regardless of what the xattr says.
For non-ELF binaries, the xattr is the only path — there is no section to find. These binaries are signed via xattr exclusively.
Limits and constants #
For completeness:
| Limit / constant | Value |
|---|---|
| Signature blob size | 3310 bytes (1 byte version + 3309 bytes ML-DSA-65 signature) |
| Version byte | 0x01 in v0.20; anything else is treated as unrecognised |
| Content hash | SHA-256 (32 bytes) |
| Signature algorithm | ML-DSA-65 pure, empty context (not HashML-DSA) |
| ELF section name | .peios.sig |
| ELF section type | SHT_PROGBITS |
| Xattr name | security.peios.sig |
| Detached file convention | <binary>.sig (build-time only) |
See also #
- Binary signing — what the signature accomplishes.
- Verification and pinning — how the kernel consumes this format at exec.
- Keys and image build — who produces these blobs and with what key.
Verification and pinning
Peios / Peios Security Fundamentals / Binary Signing
When a process execs, the kernel runs the signing layer between resolving the binary and starting the new program. The flow is: find the signature, verify it, set the new PSB's PIP fields based on the result, then proceed with the exec. If the binary verifies, the resulting process is PIP-protected at the level the catalog says; if not, it runs as pip_type = None.
This page covers the verification flow, the stable-snapshot rule that handles concurrent writes during verification, what happens to the binary's inode after a successful verification, how scripts and interpreters work, and the symlink resolution rule.
The verification flow #
flowchart LR
A["execve()"] --> B["Resolve binary"]
B --> C["Find signature"]
C -->|no signature| N["pip_type = None"]
C -->|signature present| V["Verify against key catalog"]
V -->|invalid| N
V -->|valid| P["Look up key's PIP level"]
P --> S["Set PSB pip_type/pip_trust"]
N --> X["Continue exec"]
S --> X
X --> R["Process starts running"]
In order:
- The kernel resolves the path. Standard exec resolution — symlinks followed, mount-policy honoured — produces the target file. The resolution rules for symlinks are covered below.
- The kernel looks for a signature. For ELF binaries, it scans the section header table for
.peios.sig. For non-ELF binaries, it reads thesecurity.peios.sigxattr. If neither is present, the binary is unsigned. - The kernel computes the content hash. SHA-256 over the binary, following the rules from Signature format — section contents zeroed for ELF, full file for non-ELF.
- The kernel verifies the signature. ML-DSA-65 verification of the 3309-byte signature against the 32-byte hash, using each of the public keys in the catalog in turn. If any key verifies the signature, the kernel records which key matched.
- The kernel looks up the key's PIP level in the catalog. The catalog entry says
pip_type = X, pip_trust = Y. These values are written to the new process's PSB. - Or, on any failure, the PSB's PIP fields are set to
pip_type = None, pip_trust = 0. The exec proceeds. - The exec continues. The new program starts running.
Note that step 6 also fires for any of: signature absent; signature present but version byte unrecognised; signature present but no key in the catalog verifies; signature present and verifies, but the catalog entry is somehow malformed. The kernel does not distinguish these cases in the result — they all produce pip_type = None.
The stable-snapshot rule #
A subtle case the kernel handles: what if the binary is being written to during verification?
The kernel reads the binary's content twice during exec — once to compute the hash, once (potentially) when mapping the binary into the new process's address space. If the file size or content changes between these reads, the hash computed in the first read might not match the bytes that end up executed in the second.
The kernel addresses this with a stable snapshot: the file's size is recorded at the start of verification, and the kernel only hashes bytes within that size. If the file size changes during verification, the kernel detects the change and treats the binary as unsigned (the hash was computed over a state that may no longer be valid). The resulting process runs at pip_type = None.
This is the "invalid or unstable = unsigned" case. The kernel does not retry; it does not block until the file stabilises; it simply gives up on verification for this exec. The same binary, exec'd a moment later when writes have stopped, will verify normally.
The rule applies only to the read side of the snapshot. Once the kernel has the hash and has verified the signature, the file is pinned (see below) and further writes are rejected — there is no window after verification where the binary's bytes can drift.
Scripts and interpreters #
A script — a text file starting with a shebang line — is not a signed binary. Scripts are interpreted by another program (the interpreter named in the shebang); they themselves carry no executable code that the kernel verifies. So how does signing interact with them?
The answer: the process's PIP comes from the interpreter, not the script. When you exec a script, the kernel actually execs the interpreter, passing the script as an argument. Verification runs on the interpreter binary. The resulting process's PIP is whatever the interpreter's signature says.
The consequence:
- A signed-at-TCB interpreter (say, a Python signed at TCB level) running an untrusted script runs that script as a TCB process. The script's content is irrelevant; PIP comes from the interpreter.
- An unsigned interpreter running a signed script runs as None — there is nothing to verify on the script.
- Two scripts running under the same interpreter run at the same PIP level, regardless of what the scripts do.
In v0.20 no interpreter is signed at the TCB level. The TCB binaries (peinit, authd, etc.) are compiled binaries, not interpreters. This is intentional: signing an interpreter at TCB level would trivially give every script running under it TCB authority, which is exactly the wrong tool. If a future Peios version signs interpreters, they will be signed at lower trust tiers (App, perhaps) where the script content is also trusted.
For administrators: do not assume scripts are protected by PIP. They run at the interpreter's level, which in v0.20 is None for every common interpreter. If you need a script to run as a trusted process, compile it into a binary (or run it under a future Peios-blessed interpreter, when one exists).
Symlink resolution #
When exec is given a path that is a symlink, the symlink is followed before the signing check runs. The kernel resolves the symlink chain to the final target file, then runs verification on that file. The signature on the target is what counts; signatures on intermediate symlinks are ignored.
Specifically:
- A symlink itself does not carry a signature. Symlinks are filesystem metadata, not executable content.
- A signature xattr on a symlink is not consulted by the kernel.
- The verification runs on the target's bytes, with the target's signature (xattr or ELF section).
This is the natural behaviour — exec follows symlinks anyway, and the signature is a property of the binary, not the path. But it has one implication worth noting: replacing a binary by changing a symlink's target is also changing what the kernel will verify. A symlink that currently points at a signed TCB binary, redirected to point at an unsigned attacker-controlled binary, will cause exec to run the latter and assign pip_type = None. The signing layer does not detect that the symlink target changed; it only verifies whatever the resolution lands on.
This is the right place for protection to come from elsewhere — typically the DACL on the symlink (denying writes by non-administrators) plus the DACL on the target directory. Signing is a content check; path integrity is the filesystem's job.
Content pinning #
Once a binary has been successfully verified and the resulting process has been assigned a non-zero pip_type, the kernel pins the binary's inode. From that moment on:
- Writes are rejected. Any attempt to
write()to the file (via any open fd) is denied. - Truncation is rejected.
truncate(),ftruncate()on the file are denied. - fallocate is rejected. Anything that would mutate the file's data or size, including hole-punching and zero-fill operations.
- xattr mutation of
security.peios.sigis rejected. The signature itself cannot be modified once verified. Other xattrs may be touchable (depending on FACS policy), but the signature xattr is part of the pinning.
The pin is on the inode, not the path. A file with the same name created at the same location with a new inode is not pinned; it is a separate file with a separate signature.
This is the "update by inode replacement" pattern. To update a verified binary:
- Place the new (signed) binary at a different path.
- Rename it to overwrite the old path. The rename creates a new inode at the target name; the old inode (still pinned) is no longer reachable by name.
- Once no process is holding the old inode open, it gets reclaimed.
The new binary at the new inode is unverified until something execs it. The first exec triggers verification, and if successful, that new inode is pinned in turn.
This is also the deletion pattern for a verified binary: the file can be deleted via unlink() (the name goes away; the inode stays alive as long as anyone has it open), but the file cannot be modified in place. Atomic replacement is the only update path.
Why pin? #
Pinning exists because verification is a snapshot. The kernel verified the bytes at one moment; later modifications to those bytes would mean the running process's PIP no longer matches what is on disk. Pinning prevents that drift.
Consider the alternative: a verified TCB binary whose bytes are modifiable. An attacker who can write to that file could change its contents to inject malicious code. Future execs of the same path would still verify (the signature is over the old bytes; the section/xattr still says it is signed), but the actual bytes running would be the modified ones. The kernel would unwittingly grant TCB authority to malicious code.
Pinning closes the gap. After verification, the bytes are locked. The next exec re-reads the bytes and re-verifies — same bytes, same signature, same result. There is no window in which the on-disk file differs from what was verified.
The pin is in-kernel state, not a filesystem flag. A reboot loses all pins; the next exec of each binary verifies again from scratch. This is fine because the pins are operationally short-lived — they exist for the duration of a binary's use after first exec, which is typically the lifetime of the kernel.
A subtle case: pinning before exec completes #
A binary that is being verified has not yet finished exec'ing. The pin happens after verification completes; before that, the binary is just a file. If verification fails (returns "unsigned"), the file is not pinned — there is nothing to pin, since the resulting process has pip_type = None and no other process has gained a TCB-relevant interest in this inode.
But if verification succeeds and exec then fails for unrelated reasons (out of memory, mmap failure, etc.), the PIP values are not committed to the PSB. The pin, however, has been applied. A subsequent exec of the same file will re-verify and re-pin (or rather, find the pin already in place), but the original failed exec does not roll back the pin.
This is consistent with the pin being about the inode's bytes, not about any specific process. Once an inode has been verified, the kernel knows what those bytes are and will not let them change, independent of whether any specific exec succeeded in using them.
Where to go next #
For where the public keys come from and how signed binaries are produced, read Keys and image build.
For the exact blob layout and content-hash rules verification relies on, read Signature format.
For what the assigned PIP fields go on to enforce, read Process integrity protection.
Keys and image build
Peios / Peios Security Fundamentals / Binary Signing
A signed binary's trust level is whatever the key that signed it says it should be. The mapping from "this key" to "this PIP level" is the kernel's public-key catalog. The catalog is compiled into the kernel image; there is no way to add keys to a running system, and there is no way to remove them either short of replacing the kernel.
This page covers the catalog's structure, who holds the private keys, how peiso (the image builder) uses them, the constraints on key handling, and the v0.20 limitation of having only one key.
The catalog #
The kernel holds an in-memory table mapping each known public key to the PIP level its signature confers. Each entry is structured:
| Field | Size | Meaning |
|---|---|---|
| Public key | 1952 bytes | The raw ML-DSA-65 public key. |
pip_type | 4 bytes (little-endian u32) | The PIP type this key represents. |
pip_trust | 4 bytes (little-endian u32) | The PIP trust level this key represents. |
Each entry is 1960 bytes. The table is a contiguous array of entries terminated by an all-zero entry — the kernel walks until it sees a sentinel.
At exec, when the kernel verifies a signature, it tries each key in the table in turn until one verifies the signature or the table runs out. If a key verifies, its pip_type and pip_trust are written to the new process's PSB. If no key verifies, the binary is treated as unsigned.
The table is part of the kernel image. It is read-only at runtime; there is no syscall to add an entry, modify an entry, or remove one. Replacing the table requires replacing the kernel.
Why ML-DSA-65 #
FIPS 204 defines three parameter sets — ML-DSA-44, ML-DSA-65 and ML-DSA-87 — trading signature size against security margin. Peios uses ML-DSA-65, and the deciding constraint is where signatures live rather than cryptographic preference.
Non-ELF files carry their signature in the security.peios.sig extended attribute. Most ext4-family filesystems cap an xattr value at one filesystem block, typically 4096 bytes, unless the large-xattr (ea_inode) feature is enabled. ML-DSA-65's 3310-byte blob fits inside that with room to spare; ML-DSA-87's would be 4628 bytes and would not fit. ML-DSA-44 would fit too, but saves under a kilobyte per signature in exchange for a smaller security margin — a poor trade for an OS expected to have a long life.
So ML-DSA-65 is the largest parameter set that keeps signatures storable as ordinary extended attributes on ordinary filesystems.
What v0.20 actually contains #
In v0.20, the catalog has one entry: the TCB key. The mapping is:
| Key | pip_type | pip_trust | What it represents |
|---|---|---|---|
| TCB public key | 512 (Protected) | 8192 | Peios TCB binaries — peinit, authd, loregd, lpsd, eventd |
That is it. No App-level key, no Authenticode key, no AntiMalware key. The categorical PIP labels described in Process integrity protection — Protected/1024 for Authenticode, Protected/1536 for AntiMalware, Protected/2048 for App, Protected/4096 for Peios — are defined in the model but no key in v0.20 corresponds to them. No binary on a v0.20 system will have these PIP values because nothing exists to sign them at those levels.
The implication: every signed binary on a v0.20 Peios system runs at PIP Protected/8192. There are no other PIP-protected processes. PIP is binary: a process is either TCB-level or unprotected.
Future Peios versions will add keys for the other tiers. App-level signing will let Peios-distributed applications get their own PIP protection (mid-trust); Authenticode-level will let third-party signed binaries run at low-but-non-zero trust; AntiMalware-level will protect security tooling. The catalog grows; the model accommodates it without code changes.
The TCB private key #
The TCB private key is what peiso uses to sign TCB binaries at image build. It is the most security-sensitive artefact in the entire system: anyone with the TCB private key can sign a binary that the kernel will then trust at the highest level. There is no revocation, no per-binary blocklist, no way to invalidate a signed binary short of removing it.
The other constraints on the private key are just as absolute:
- The key is held by whoever builds the system image (in practice, the Peios project's release infrastructure for the official distribution; potentially a downstream packager for a fork).
- The key is not distributed. Users do not get a copy of the private key. They get the binaries the private key was used to sign.
The standard pattern: build infrastructure with the private key produces image artefacts. The artefacts include the public key compiled into the kernel image and the signed TCB binaries. The artefacts ship; the private key stays in the build infrastructure.
A system whose private key has been compromised loses the security guarantees of PIP. There is no way to retroactively recover from such a compromise; the only fix is to release a new image with a new public key and re-sign all TCB binaries with the new private key, then deploy. Existing systems running the old image continue to trust the old key.
peiso and the build flow #
peiso is the Peios image builder. Its job is to assemble a complete bootable Peios image — kernel, root filesystem, signed binaries, build artefacts. Signing is one of its tasks.
At build time, peiso:
- Compiles or assembles the kernel image. The public-key catalog is built into this image; peiso embeds the kernel-build's public keys (or asserts that they are already embedded).
- Collects the binaries that should be signed at TCB level. The full list is defined per release — typically peinit, authd, loregd, lpsd, eventd.
- For each binary, peiso:
- Computes the appropriate content hash (ELF section zeroed for ELF, full file for non-ELF — see Signature format).
- Signs the hash with the TCB private key, producing a 3309-byte ML-DSA-65 signature.
- Constructs the 3310-byte blob (version byte + signature).
- For ELF binaries: reserves the
.peios.sigsection (3310 zero bytes) before hashing, then writes the blob into the reserved section after signing. Because the section is zeroed during hashing per the ELF rule, the hash the kernel computes at verification time matches the one that was signed. - For non-ELF binaries: writes the blob as a detached
.sigfile next to the binary. At image-assembly time, peiso reads the detached file and stamps thesecurity.peios.sigxattr on the binary in the image's filesystem.
- The signed binaries are placed in the image. The detached
.sigfiles are discarded. - The image is finalised and emitted as an installable artefact.
At runtime — when the image boots — the kernel reads its own embedded public key, the TCB binaries' embedded sections (or xattrs) provide their signatures, and verification proceeds. No private key is involved at runtime.
Why one key in v0.20 #
A reasonable question: why does v0.20 ship with only the TCB key? Why not at least an App key for Peios-distributed applications?
The answer is that the App, Authenticode, and AntiMalware tiers require infrastructure that v0.20 does not have:
- App-level signing requires a Peios-managed signing service that signs released applications. The service exists conceptually; the operational pipeline does not.
- Authenticode-level signing requires a trust agreement with one or more third-party CAs or a Peios-managed equivalent. None of that is in place.
- AntiMalware requires a vetting process for security-tooling vendors. Same status.
The TCB key, by contrast, is just the project's own key, used to sign the project's own binaries. The infrastructure is the project's own release pipeline. It works on day one.
Future Peios versions will add the other tiers as the corresponding infrastructure comes online. The model accommodates them. The catalog grows. Until then, the only PIP-protected processes are TCB processes, and the model effectively has two levels: TCB and not-TCB.
Implications for ordinary deployments #
For someone running Peios:
- No signing tools are available on a running system. You cannot sign your own binaries to get PIP protection. The TCB private key is not available; no other key is defined.
- Binaries you write or compile yourself run as
pip_type = None. Including binaries built from source on the running Peios system. This is correct behaviour — there is no path for an untrusted-by-the-OS binary to acquire trust. - The only PIP-protected processes are TCB. peinit, authd, loregd, lpsd, eventd, and that is it. Everything else runs at None.
- Updates to TCB binaries come through the package system. A new release of authd, say, is in a
.peipkgsigned by the project's package-signing infrastructure (separate from the TCB binary signing — packages have their own signature for distribution integrity). The binary inside the package carries the TCB-key signature for PIP. Installing the package places the binary on disk; the kernel verifies it at next exec.
For administrators of a custom Peios fork (using their own kernel image with their own key catalog), the same model applies with whatever keys they have defined: their TCB-equivalent key signs their TCB binaries, those binaries run at TCB-equivalent PIP, and nothing else has PIP protection unless they add keys to their catalog.
Where to go next #
For the enforcement flow these keys feed — verification at exec and inode pinning — read Verification and pinning.
For the per-process hardening flags that build on signing — LSV gates executable mappings by signature trust — read Process mitigations.
Process mitigations
Peios / Peios Security Fundamentals / Process Mitigations
A mitigation is a per-process kernel-enforced hardening rule. Where the DACL, PIP, and the access check decide what objects a process may reach, mitigations decide what a process may do with itself — what regions of its own memory may be made executable, how its address space is laid out, what kinds of indirect control flow are permitted, what it can do with child processes.
Mitigations are stored on the process's PSB (Process Security Block) as a set of boolean flags. The PSB is the same per-process structure that holds the PIP fields and the process SD — covered in Process integrity protection. Each mitigation has its own flag; each flag controls one specific kernel-enforced behaviour.
The mitigation model is one-way: each flag can be turned on but never turned off. Once a process has enabled WXP (write-XOR-execute), it cannot disable WXP. The flag survives exec, so a child binary launched into a process that had enabled the mitigation inherits the constraint.
This page covers the model — what mitigations are, how they fit in alongside the other access-control layers, what the one-way and exec-preservation rules mean, and who sets them.
What mitigations protect against #
Mitigations exist for a different threat model than access control. The DACL protects an object from unauthorised callers; the access check decides whether the caller has the right to act. Mitigations protect a process from its own bugs and from injected code.
The motivating scenario for most mitigations is the same: a process has a memory-safety bug — a buffer overflow, a use-after-free, an integer overflow — that an attacker exploits to redirect execution. The exploit typically wants to do one of:
- Inject new executable code (shellcode) into the process and jump to it.
- Reuse existing code in unexpected ways (return-oriented programming, jump-oriented programming).
- Hijack indirect branches to land at attacker-chosen targets.
- Modify already-loaded code in place.
Each mitigation closes one or more of these doors. The mechanism is uniform: the kernel refuses the request that would enable the exploit, even though the syscall or memory operation looks legitimate. A process that has enabled WXP cannot mmap a writable-and-executable page; the kernel returns an error. A process with TLP enabled cannot mmap-as-executable a file outside the approved-paths cache; the kernel refuses.
The result is not "the bug is fixed" — the bug is still there, and the exploit may still be able to corrupt memory. What changes is what the exploit can do with the corrupted memory. A successful exploit on a process without mitigations gives the attacker arbitrary code execution; the same exploit on a process with mitigations gives the attacker a crashed process (the kernel refused the operation and the process aborts).
How mitigations differ from access control #
The two layers solve different problems:
| Access control | Mitigations | |
|---|---|---|
| What it gates | Operations against other objects (files, registry keys, processes) | Operations the process performs on its own memory and address space |
| Driven by | Identity and policy (token, SD, privileges) | Hardening posture chosen at process startup |
| Granularity | Per-object | Per-process |
| Adjustability | Identity can adjust within rules (AdjustPrivileges) | One-way; only ever tightened |
| Who sets it | authd (token), object owner / administrator (SD) | The launching process (typically peinit) or the process itself |
| Threat model | Untrusted callers reaching trusted objects | Code-execution exploits in the process's own memory |
A process can be subject to both layers simultaneously. A TCB daemon has restrictive access control (only TCB-level callers can interact with it) and a strict set of mitigations (WXP, LSV, TLP, CFI, PIE, SML). The two layers reinforce each other: access control keeps untrusted callers out, mitigations keep the process from being exploited even if untrusted input does reach it.
A process can also be subject to one without the other. An unprotected user-mode binary has no PIP, an open DACL, and no mitigations — it depends on the access control of objects it touches but has no internal hardening. The opposite — strict mitigations with permissive access — is less common in practice but legal.
The PSB storage and the one-way rule #
The mitigations live in a small bitfield on the PSB:
| Flag | Bit value |
|---|---|
| WXP | 0x001 |
| TLP | 0x002 |
| LSV | 0x004 |
| CFI (legacy alias for CFIF + CFIB) | 0x008 |
| UI_ACCESS | 0x010 (reserved) |
| NO_CHILD | 0x020 |
| CFIF | 0x040 |
| CFIB | 0x080 |
| PIE | 0x100 |
| SML | 0x200 |
| ALL | 0x3FF |
Each bit is independent. Setting a bit enables the mitigation; the bit can be set but never cleared. The kernel rejects any operation that would clear a previously-set bit.
The one-way rule is what makes mitigations trustworthy. A process that has WXP enabled cannot be tricked or coerced into disabling it. There is no syscall to clear a mitigation; there is no privilege that bypasses the rule. Once on, on for the lifetime of the process (and beyond — see below).
The same applies to NO_CHILD — bit 0x020 in the same bitfield. Once set, the process can never fork or clone again. There is no way to undo it.
Exec preservation #
When a process execs a new binary, almost everything about the process resets. The address space is wiped, the new binary is mapped, the entry point runs. But the PSB's mitigations survive exec. A process that had WXP enabled before exec still has WXP enabled after; the new binary inherits the constraint.
This is the rule that makes the one-way model genuinely one-way. Without exec preservation, an attacker who could control what binary the process execs could trivially "unset" the mitigations by exec'ing a binary in a fresh address space — but the kernel does not give the attacker that escape. The flags travel with the process, not with the binary.
The corollary: a binary that fundamentally cannot operate under a given mitigation (a JIT compiler under WXP, say) cannot be exec'd into a process that has that mitigation set. The exec will succeed (the kernel does not gate exec on mitigations), but the binary's first attempt to do the thing it needs (mmap PROT_EXEC of newly-written code) will fail with the appropriate error. The result is a process that runs the binary's startup code and then crashes when it tries to do its job.
Practical implication: deciding which mitigations to enable is a per-process decision made at startup, based on knowledge of what binary will run there. peinit knows that authd cannot tolerate WXP-incompatible operations because authd was compiled to be WXP-compatible. So peinit sets WXP on the authd process. A process launching arbitrary user binaries cannot make the same assumption; setting WXP on a user shell would break any JIT or self-modifying binary the user happened to run.
Who sets mitigations #
Three patterns for setting mitigations:
- peinit, before exec. When peinit launches a service, it forks, sets the desired mitigations on the child's PSB via
kacs_set_psb, then execs the service binary. The service comes up with the mitigations already in place. This is the standard pattern for system services. - The process itself, after startup. A process can set mitigations on its own PSB. The typical pattern is "after the early-startup work (which may need to relax some constraints), set the mitigations and continue with the constrained code". Self-application of mitigations is a hardening best practice for binaries that have a clear startup-then-steady-state split.
- A privileged supervisor. A process with
PROCESS_SET_INFORMATIONon the target and the appropriate PIP dominance can set mitigations on another process. Rare in practice — most mitigation-setting is either at exec time (peinit) or by the process itself.
In all three cases, the call is kacs_set_psb. The full mechanics — what fields can be set, what fails, who needs what privilege — are in Applying and lifecycle.
What the kernel actually does when a mitigation fires #
Each mitigation has its own enforcement points. The pattern is the same: a syscall the process is about to make is checked against the relevant mitigation; if the operation would violate the mitigation, the kernel returns an error (typically -EACCES or -EPERM) rather than performing the operation.
The process can then handle the error. In most cases, encountering a mitigation-blocked operation is unexpected — the process did not anticipate it could happen — and the result is a crash. In some cases, the process handles the error gracefully by falling back to a different code path. The kernel does not decide which; it just refuses the operation.
This is uniform across mitigations:
- WXP refuses
mprotectcalls that would transition pages W→X. - LSV refuses
mmap(PROT_EXEC)of unsigned or insufficiently-trusted files. - TLP refuses
mprotect(PROT_EXEC)of pages backing files outside approved paths. - CFIF refuses indirect branches that land outside ENDBR (or equivalent) target instructions.
- PIE refuses exec of non-PIE binaries (this one fires at exec, not at runtime).
Each is enforced by the kernel at the syscall layer. There is no userspace component. The process cannot bypass them by avoiding libc; the syscalls themselves carry the check.
Where to start #
If you want the catalog — what each individual mitigation does, when it fires, what kernel surfaces it covers — read Catalog.
If you want the operational mechanics — kacs_set_psb, the right syscall and privilege requirements, the lifecycle of a mitigation flag from initial set through fork and exec — read Applying and lifecycle.
Catalog
Peios / Peios Security Fundamentals / Process Mitigations
There are eight mitigations active in v0.20 plus a reserved slot. This page covers each one. Every mitigation works the same way structurally — a flag on the PSB; the kernel checks the flag at the syscall layer; offending operations are refused — but each gates a different kind of operation. Understanding the catalog is understanding which threats each closes off. The numeric KACS_MIT_* flag values are cataloged in Other constants.
LSV — Library Signature Verification #
LSV is the mitigation that decides which executable code may be loaded into the process from disk. With LSV enabled, mmap(PROT_EXEC) requires that the file backing the mapping carry a valid signature whose PIP trust level is at least the calling process's pip_trust. Unsigned files cannot be loaded executable; files signed at a lower trust level cannot either.
The kernel's behaviour:
- When
mmap(..., PROT_EXEC, ...)is called on a file fd, the kernel checks the file's signature. - If the file has no signature, or its signature is invalid, the call fails with
-EACCES. - If the file's signature is valid but its PIP trust level is below the calling process's, the call also fails with
-EACCES. - If the file's signature is valid and at sufficient trust, the call proceeds.
The result: a TCB process (pip_trust = 8192) can only load TCB-signed libraries. A future App-signed process (pip_trust = 2048) could load App-signed or higher libraries but not Authenticode-signed or unsigned ones. A None-trust process is not subject to LSV (LSV does not check against None; the process can load anything).
LSV is the mitigation that closes the "load arbitrary shared object" injection path. An attacker who has overwritten a function pointer in the process to point at dlopen("evil.so") finds that the call fails — the kernel refuses to map the unsigned library as executable.
LSV is appropriate for any PIP-protected process. peinit, authd, and the rest of the TCB all run with LSV enabled.
WXP — Write-XOR-Execute #
WXP refuses any operation that would transition a memory page from writable to executable. Pages can be writable, or executable, but not both at any moment, and not become executable having been writable.
Specifically:
mmap(..., PROT_WRITE | PROT_EXEC, ...)is refused if WXP is enabled.mprotect(addr, len, PROT_EXEC)on pages previously protected with PROT_WRITE is refused.mprotect(addr, len, PROT_WRITE)on pages previously protected with PROT_EXEC is refused.
The check fires per-page. The kernel tracks the protection history of each mapping; a page that has ever been writable cannot subsequently be made executable, and vice versa.
The effect: JITs (Just-In-Time compilers) cannot run with WXP enabled. A JIT's whole job is to allocate a region of writable memory, generate code into it, then flip the region executable — exactly what WXP refuses. Binaries that need this flexibility (managed-language runtimes, dynamic-recompilation engines) cannot be hardened with WXP.
Binaries that do not generate code at runtime are unaffected. A normal native binary loads its code from disk at exec (PROT_EXEC granted by exec, not by mprotect), uses stack and heap as PROT_READ | PROT_WRITE, and never needs to flip pages between write and execute. WXP is invisible to such binaries.
WXP is one of the cornerstones of process hardening. It closes the "inject shellcode and jump to it" pathway: an attacker who has corrupted memory cannot make their corrupted region executable. The exploit's payload, no matter how big, is just data.
TLP — Trusted Library Paths #
TLP gates which directories executable mappings may come from. The kernel maintains a per-system cache of approved directory prefixes (populated from the registry at boot); when TLP is enabled, a process can only mprotect(PROT_EXEC) (or mmap(PROT_EXEC)) a region backed by a file whose resolved absolute path begins with one of those prefixes.
The check:
- The kernel resolves the file's path to an absolute pathname.
- The absolute path is compared against each entry in the TLP cache.
- If the path begins with any entry's prefix, the operation proceeds.
- If not, the operation fails with
-EACCES.
The TLP cache holds typical system library paths: /usr/lib/, /lib/, perhaps a few service-specific paths. Anything in /tmp/, /home/, or in directories not explicitly in the cache is excluded.
The threat TLP closes is "load executable code from a writable directory". A code-injection attack that writes a shared object into /tmp/ and loads it via dlopen finds the load refused — /tmp/ is not in the TLP cache. The same attack that puts the file in /usr/lib/ is much harder; non-administrators cannot write to /usr/lib/ in a standard configuration.
TLP composes naturally with LSV. LSV says "the library must be signed at sufficient trust"; TLP says "the library must come from an approved location". Both are typically enabled together on hardened processes; either alone is a useful but partial defence.
TLP cache details:
- Maximum 64 entries.
- Each entry is a UTF-8 absolute directory prefix, beginning and ending with
/. - Maximum 4096 bytes per path.
- Populated by peinit at boot from the registry. The cache is machine-wide; every process sees the same prefixes.
CFIF and CFIB — Control Flow Integrity (Forward and Backward) #
CFIF (Forward) and CFIB (Backward) are the two halves of control-flow integrity. They are separate flags so a process can enable one without the other, though most hardened processes enable both. The combined effect is to refuse any indirect branch (forward or return) that lands at an unintended target.
The legacy flag CFI (0x008) is an alias that sets both CFIF and CFIB. Modern code should set CFIF (0x040) and CFIB (0x080) directly; the alias exists for compatibility.
CFIF — Forward CFI #
CFIF refuses indirect calls and jumps (function pointers, vtables) that land at an instruction other than a designated call target. On x86_64, the hardware mechanism is IBT (Indirect Branch Tracking) — every legitimate target of an indirect branch is marked with an ENDBR64 instruction; an indirect branch landing somewhere without ENDBR64 traps.
With CFIF enabled, the kernel ensures the process runs in IBT-enforcing mode. An indirect branch to a non-ENDBR64 target generates a control-protection fault, which the kernel turns into a fatal signal to the process.
The threat closed: ROP/JOP (Return-Oriented / Jump-Oriented Programming) attacks that chain together short "gadgets" found in legitimate code. The gadgets are short sequences ending in ret or indirect jump; without CFIF, an attacker can use them to perform arbitrary operations. With CFIF, the gadgets are no longer reachable because the indirect branch into them lands somewhere without ENDBR64.
CFIB — Backward CFI #
CFIB refuses ret instructions that do not return to the address pushed by the corresponding call. The hardware mechanism is the shadow stack — a separate stack maintained by the CPU that records return addresses. Every call pushes onto both the data stack and the shadow stack; every ret pops both and compares them. A mismatch traps.
With CFIB enabled, the kernel ensures the process runs with the shadow stack engaged. An attacker who overwrites a return address on the data stack cannot get the ret to honour their overwrite — the shadow stack still has the original address; the comparison fails; the process dies.
The threat closed: classic ROP. Overwriting return addresses is the foundation of return-oriented exploitation; CFIB makes the trick impossible (or, more precisely, makes it lead to immediate process termination instead of attacker-chosen execution).
Both CFIF and CFIB require hardware support (Intel CET, ARM BTI/PAC, etc.) plus binary support (the binary must have been compiled with the relevant flags). Enabling CFIF or CFIB on a process whose binary does not support it has no effect — the kernel can only enforce what the hardware can detect.
PIE — Position-Independent Executable #
PIE refuses exec of binaries that are not position-independent. The kernel checks the binary's ELF flags at exec; if PIE is enabled on the parent process's PSB and the new binary is not PIE-built, the exec fails with -EACCES.
PIE-built binaries are loaded at randomised addresses every time they exec — Address Space Layout Randomisation (ASLR) covers the executable's own segments, not just the heap and shared libraries. An attacker who would have needed to know the address of a specific instruction in the binary to construct a ROP chain finds the address randomised and unpredictable.
A non-PIE binary has fixed addresses for its code and globals. Every exec puts them in the same place. An attacker can compute exploitation gadget addresses once and reuse them across runs.
PIE is the easiest mitigation to enable: any modern compilation with -fPIE -pie produces a PIE binary. Distributions of Peios produce PIE binaries by default. The mitigation simply refuses to exec the rare exception.
The cost: a small (single-digit-percent) performance overhead because PIE binaries reference memory through GOT/PLT tables rather than direct addresses. The cost is dwarfed by the security benefit on any modern CPU.
SML — Speculation Mitigation Lock #
SML enables the most paranoid set of speculative-execution mitigations for the process — Spectre, Meltdown, MDS, and the rest of the side-channel family. With SML enabled, the kernel applies all relevant mitigations on every context switch into and out of this process: indirect-branch barriers, store buffers cleared, microcode flushes, anything the CPU supports for spectre-class defences.
The cost is significant — depending on the CPU and the workload, anywhere from a few percent to tens of percent of performance. Most workloads do not need it. The processes that do need it are the ones handling secrets that an attacker on the same machine could otherwise extract through speculative-execution side channels: cryptographic key holders, sealed-secret stores, the TCB processes that hold sensitive material.
SML is per-process. A SML-enabled process pays the cost; processes without SML in the same kernel use the default (lighter) set of mitigations.
The threat SML closes: an attacker who has unprivileged code running on the same machine (or in some configurations, even on a different machine sharing the same CPU) using speculative-execution timing side channels to extract data from a victim process. Without SML, the standard mitigations may be enough for most attacks; with SML, the process is hardened against even the most subtle.
NO_CHILD — Forbid fork and clone #
NO_CHILD refuses fork, clone, and any other path that would create a new process or new thread sharing the current address space. (CLONE_THREAD-style clones to add threads to the current process are not covered by NO_CHILD; the flag only refuses new processes.)
With NO_CHILD enabled:
fork()returns-EPERM.clone()returning a new process returns-EPERM.clone3()with similar flags returns-EPERM.
The threat closed: an exploit that has gained code execution in the process attempting to spawn a helper process to do its work. NO_CHILD makes that path impossible — the process is locked into being a single process; whatever the attacker does, they cannot fork out of it.
NO_CHILD is appropriate for processes that fundamentally do not need to fork. Many long-lived services (a single-process event-loop daemon, for example) never call fork in their normal operation. Setting NO_CHILD on such a service costs nothing operationally and closes a frequently-abused exploitation path.
Services that do fork during their operation (a classical Unix accept-fork-handle server) cannot use NO_CHILD. Either restructure the service to use threads or async I/O, or accept that NO_CHILD does not fit.
NO_CHILD does not prevent threads in the same process — pthread_create and its kin are CLONE_THREAD-style operations and remain available. The mitigation is specifically about new processes, not new threads.
UI_ACCESS — Reserved #
UI_ACCESS (0x010) is reserved in v0.20. The intended use is for processes that interact with the user interface in privileged ways; the mitigation, when defined, will restrict what UI surfaces the process may attach to.
In v0.20 the flag has no effect — setting it is allowed but does nothing. Future versions may define behaviour.
Combining mitigations #
Most hardened processes enable multiple mitigations. A typical TCB daemon's mitigation set in v0.20:
- WXP — no writable-executable pages
- LSV — only signed libraries
- TLP — only libraries from approved paths
- CFIF + CFIB — control-flow integrity
- PIE — ASLR-aware binaries
- NO_CHILD if applicable — no child processes
Plus possibly SML for secrets-handling processes.
The mitigations compose orthogonally — each one closes a different attack pathway. WXP closes shellcode injection; LSV closes signed-library-only-rule; TLP closes load-from-writable-directory; CFI closes ROP/JOP; PIE closes fixed-address attacks; NO_CHILD closes process-spawning escapes; SML closes speculative-execution side channels. A process with all of them is meaningfully harder to exploit than one with any subset.
The ALL flag (0x3FF) is shorthand for "enable every defined mitigation". A process that wants the strictest possible hardening can set ALL in one call.
What mitigations don't help with #
A few clarifications worth pinning:
- Mitigations do not protect against logic bugs. A process that has been tricked into doing something its code is allowed to do but should not (a misconfigured permission check, a misused API) is not protected by mitigations.
- Mitigations do not protect against bugs in the kernel. A kernel exploit operates above the mitigation layer; mitigations are kernel-enforced, and a compromised kernel can disable them.
- Mitigations do not protect against bugs in the language runtime that bypass them. A JIT that legitimately needs writable-executable pages is incompatible with WXP; running it with WXP either disables the JIT (which may fail in unexpected ways) or refuses to set WXP at all.
- Mitigations do not catch all exploits. They are layered defences. An exploit that fits within one mitigation's blind spot (a JIT spray attack against a non-WXP process, say) succeeds despite the other mitigations being on. Defence in depth is the goal — the more layers, the more an exploit must defeat.
See also #
- Process mitigations — the model these flags share.
- Applying and lifecycle — setting the flags and how they propagate.
- Binary signing — the signatures LSV verifies against.
Applying and lifecycle
Peios / Peios Security Fundamentals / Process Mitigations
Setting mitigations is a small kernel operation — call a syscall, pass a bitmask, the kernel sets the bits on the PSB. The complications are not in the call itself; they are in who can make the call, when in a process's life it can be made, and how the resulting state propagates through fork and exec.
This page covers the operational mechanics: the kacs_set_psb syscall, the privilege rules for setting mitigations on another process, and the lifecycle of a mitigation flag from initial set through process exit.
kacs_set_psb #
The kernel exposes one syscall for setting mitigations:
kacs_set_psb(target_pidfd, flags)
target_pidfd is a pidfd for the process whose PSB is to be modified. The caller's own process is acceptable; another process is also acceptable (subject to privilege rules below).
flags is a bitmask combining the values from the catalog:
| Flag | Bit |
|---|---|
| WXP | 0x001 |
| TLP | 0x002 |
| LSV | 0x004 |
| CFI (legacy alias) | 0x008 |
| UI_ACCESS | 0x010 |
| NO_CHILD | 0x020 |
| CFIF | 0x040 |
| CFIB | 0x080 |
| PIE | 0x100 |
| SML | 0x200 |
| ALL | 0x3FF |
The kernel ORs the flags into the target's existing mitigation bitfield. There is no "clear" — bits cannot be removed. Calling kacs_set_psb with a subset of currently-set bits leaves the previously-set bits intact; you can never use this call to disable a mitigation.
The kernel rejects:
- A pidfd pointing at a process the caller does not have authority to modify (see below).
- Unknown flag bits (anything outside the defined set is
-EINVAL).
The kernel does not reject:
- A call setting bits that are already set. The OR is idempotent.
- A call setting
UI_ACCESS(which is reserved). It sets the bit; the bit has no effect. - A call setting flags incompatible with the current binary's capabilities. Setting WXP on a process running a JIT is allowed; the JIT will fail the next time it tries to flip a page, but the
kacs_set_psbcall itself succeeds.
Self versus another process #
The privilege required depends on whose PSB is being modified:
Setting mitigations on your own process requires no privilege. Any process can call kacs_set_psb with its own pidfd (or, more commonly, with no pidfd to mean "self"). The call always succeeds for self-targeted invocations, regardless of identity, integrity, or anything else.
The reasoning: a process can only ever tighten its own constraints. There is no risk in letting a process restrict itself further. The model assumes that any code running in the process is, by definition, code the process has chosen to run; that code wanting to add a mitigation is fine.
Setting mitigations on another process requires:
PROCESS_SET_INFORMATIONon the target process — granted by the target's process SD.- PIP dominance over the target (per the two-check rule).
This is the standard cross-process operation pattern. The caller's PSB must dominate the target's, and the target's SD must grant the appropriate right to the caller. Both must be satisfied.
In practice this means the only common caller for cross-process kacs_set_psb is peinit (when launching a service that needs mitigations applied at exec). peinit has TCB-level PIP and is granted PROCESS_SET_INFORMATION on the services it launches; it sets the desired mitigations on the child's PSB after fork and before exec.
A self-applied mitigation does not require the caller to be the process itself in the strict sense — it just requires the pidfd to point at the caller's own process. A thread within a process can set mitigations on the process's PSB regardless of which thread does the call.
Where in the process lifecycle #
A mitigation can be set at any moment during a process's life. The kernel does not require it to happen at startup, before exec, or before any specific event. Practical patterns:
- At process creation, before exec. peinit forks, calls
kacs_set_psbon the child's pidfd, then execs the service binary. The mitigations are in place when the binary starts running. This is the standard. - At the entry point of the binary. The binary itself, immediately on startup, sets its desired mitigations on its own PSB. Suitable for binaries that are self-aware about their hardening posture.
- After early-stage initialisation. A process that has work to do during early startup that needs to relax some constraints (loading executable libraries from non-approved paths, for example) waits until after that work is done, then sets the mitigation.
A mitigation set late in a process's life closes off only future operations. Operations that have already happened — pages already mapped, libraries already loaded — are not retroactively checked. WXP set after the process has already mmap'd a writable-executable region does not unmap that region; it only refuses future such operations.
This is sometimes a useful pattern: a process needs WXP-incompatible behaviour during startup (say, runtime code generation for initialisation) and then transitions to a steady state where WXP is appropriate. The pattern is "do the WXP-incompatible work first, then kacs_set_psb(self, WXP)". From that point forward, WXP is enforced.
Fork: inheritance #
When a process forks, the child inherits the parent's mitigation flags exactly. Every bit set on the parent is set on the child. The child cannot un-set them at fork time; the one-way rule applies.
This is the natural extension of one-way: a process that has chosen to lock down its execution cannot give its children more authority than it has itself. If WXP is set, every child also has WXP. If NO_CHILD is set... well, the parent cannot fork in the first place, so the question does not arise.
Threads (CLONE_THREAD-style clones) share the parent's PSB rather than copying it. A new thread in the same process is bound by the same mitigations; setting a mitigation in one thread is visible to all threads immediately.
Exec: preservation, with one wrinkle #
When a process execs, all of:
- The process identity (token) — preserved.
- The PIP fields — re-computed from the new binary's signature.
- The process SD — typically preserved, may be re-defaulted if the user identity changed.
- The mitigation flags — preserved.
The new binary runs with whatever mitigations were on the PSB before exec. There is no way for exec to relax mitigations.
The one wrinkle: PIE.
PIE is the mitigation that fires at exec, not at runtime. With PIE set, the kernel checks the new binary's ELF flags during exec; if the binary is not PIE-built, the exec fails with -EACCES. The process attempting the exec sees its execve return with an error and continues running its current binary.
For other mitigations, the exec succeeds and the new binary inherits the mitigation. PIE is the one that can cause exec itself to fail.
This means setting PIE before launching a service is a way of saying "this service binary must be PIE, or it cannot run". If the operator updates the service to a binary that is not PIE, the next exec attempt will fail and the service will not start. This is sometimes desired (a hard constraint that the binary be PIE); sometimes inconvenient (an emergency rebuild that lost the PIE flag).
NO_CHILD and the lifecycle interaction #
NO_CHILD (bit 0x020) is also stored on the PSB and follows the same one-way rules as the other mitigations. Once set, the process cannot fork or clone-with-new-process.
The lifecycle interaction worth knowing: a process that wants to set up children and then lock itself down should do the forks first, then call kacs_set_psb(self, NO_CHILD). After that call, the process cannot create more processes.
If a process needs to be able to fork on demand (a server that handles each connection in a new process), NO_CHILD is not appropriate. The fork capability and NO_CHILD are mutually exclusive in steady state.
A process with NO_CHILD set can still call exec (replacing itself with a new binary in the same process) and create threads via CLONE_THREAD. The mitigation specifically blocks the spawning of new processes.
Querying mitigations #
A process can read its own mitigation flags via the PSB query. The interface — typically through kacs_open_self_token and a query on the PSB — returns the current flag bitfield.
For reading another process's flags, the same PROCESS_QUERY_INFORMATION + PIP dominance rules apply as for setting them. Typically only peinit or a debug tool would read another process's mitigation flags.
Note that the flags are independent of the PSB's other fields. Querying the mitigation flags does not reveal anything about the process's PIP level or its SD — those are separate queries. It does include NO_CHILD, which is bit 0x020 of the same bitfield.
What happens at process exit #
A process's PSB is destroyed when the process exits. The mitigation flags vanish with it. There is no persistence; the next time the same binary is exec'd in a fresh process, the mitigations have to be re-applied.
This is why launchers (peinit) apply mitigations on every launch. There is no cached "this binary always gets these mitigations" — every fresh process starts from the inherited PSB, which is whatever the launcher's PSB had plus whatever the launcher chose to add.
The corollary: a process whose launcher does not apply mitigations runs without them, regardless of the binary's intent. A binary that wants to be hardened should also call kacs_set_psb at its own entry point so the mitigations are guaranteed regardless of who launched it. Defence in depth: both the launcher and the binary should set the mitigations they need.
Errors #
kacs_set_psb can fail with:
| Error | Cause |
|---|---|
-EBADF | Invalid pidfd. |
-ESRCH | The target process has exited. |
-EACCES | The caller does not have PROCESS_SET_INFORMATION on the target. |
-EPERM | The caller does not PIP-dominate the target (when modifying another process). |
-EINVAL | Unknown flag bits in flags. |
In normal operation, the call succeeds. Failures are typically programming errors (wrong pidfd) or insufficient authority (a low-trust caller trying to modify a high-trust target).
Where to go next #
For what each flag actually enforces once set, read the Catalog.
For the SD-plus-dominance rules that gate setting mitigations on another process, read The two-check rule.
Auditing
Peios / Peios Security Fundamentals / Auditing
Auditing is the access check's record-keeping layer. Where the DACL and the rest of the pipeline decide whether access is granted, auditing decides what to remember about that decision. An audit event records an access attempt — who made it, what they wanted, what they got, what triggered the recording — and ships the event through KMES to whatever userspace consumer is listening.
Auditing runs after the access decision is made. By the time an audit event fires, the granted mask is already final; the audit walk is not part of the gate, only of the record. This means audit events are observational, not decisional: a fired audit event reports what already happened; it does not affect whether the access happens.
This page covers the model — what kinds of audit exist, where each one fires in the pipeline, and what they have in common.
What auditing is for #
Auditing answers questions of the form "did this happen?". It does not answer "is this allowed?" — that is the access check's job, and it has already been done by the time audit runs.
The questions audit handles well:
- "Who accessed this sensitive file in the last hour?"
- "Did this principal exercise SeDebugPrivilege today?"
- "How many failed write attempts were there against this object since Monday?"
- "Which sessions are currently writing to this directory?"
The questions audit does not handle well:
- "Should this access have been allowed?" — that is policy, not audit.
- "Will this access succeed?" — audit is post-decision, not predictive.
- "What rights does this user have on this object?" — audit observes events; computing rights requires the access check.
Auditing is most valuable for compliance and incident response. A regulated environment may need to log every access to certain classes of data; an incident-response team needs to reconstruct what happened in the moments before a compromise. Auditing produces the raw record those use cases consume.
The three audit categories #
Peios produces three categories of audit event from the access pipeline:
| Category | Triggered by | When it fires |
|---|---|---|
| Object-access audit | SYSTEM_AUDIT* ACEs in an object's SACL, or the calling token's audit_policy bitmask | At AccessCheck completion (one event per matching audit ACE per access) |
| Continuous audit | SYSTEM_ALARM* ACEs in an object's SACL | Per-operation on an open handle (every operation whose required mask overlaps the handle's continuous-audit mask) |
| Privilege-use audit | A privilege contributed to the granted mask, and the token's audit_policy requested it | At AccessCheck completion (one event per privilege that fired) |
Plus a fourth, tangentially related category produced by session lifecycle:
| Category | Triggered by | When it fires |
|---|---|---|
| Session destruction | A logon session loses its last token reference | When the session is destroyed |
Object-access and continuous audit are covered in Audit ACEs. Privilege-use audit and the token-forced subset of object-access audit are covered in Policy-forced auditing. Event schemas and the KMES transport are in Events and transport.
Where audit fits in the pipeline #
flowchart LR
A["AccessCheck pipeline steps 0-12"] --> B["Granted mask + privilege contributions"]
B --> C["Step 13: privilege-use audit emission"]
C --> D["Step 14: SACL audit walk"]
D --> E["Step 14b: token audit_policy forced events"]
E --> F["AccessCheck returns: granted mask, continuous_audit mask, staging_mismatch"]
F --> G["KMES delivers events to consumers"]
Step 13 fires privilege-use events for privileges that contributed bits. Step 14 walks the object's SACL (plus any CAAP effective SACLs collected during step 12) looking for audit and alarm ACEs that match the access. Step 14b checks the calling token's audit_policy bitmask for forced events that should fire regardless of any SACL ACE.
Each of these steps can produce zero or more events. A single access check might produce no events (no audit ACEs matched, no privileges used, no token-forced auditing); or it might produce many (multiple matching audit ACEs, multiple privileges, multiple forced events).
The events are sent through KMES — Peios's kernel-to-userspace event transport — to whichever consumer is subscribed. The kernel does not retain them, does not buffer them past the transport, does not retry them; KMES is fire-and-forget at the kernel-to-userspace boundary, and everything past that boundary is the transport pipeline's concern, not the audit layer's.
What an audit event records #
Every audit event includes:
- The subject — who was making the access. The token's user SID, group SIDs, integrity level, PIP type and trust, projected UID.
- The object context — a caller-supplied opaque blob identifying the object being accessed. Audit consumers use this to correlate events with the objects they cover.
- The access — what was requested, what was granted, whether the access succeeded.
- The trigger — which ACE matched (for SACL-driven events), which privilege fired (for privilege-use events), or which audit-policy bit (for token-forced events).
- The process context — pid, name, executable path. Useful for correlating events with running processes.
The exact field set varies by event type and is covered in Events and transport.
What audit does not do #
A few clarifications:
- Audit is not the access decision. An audit event firing does not mean access was granted; an event for a denied access is just as legitimate. Read the success flag in the event to know what happened.
- Audit is not retroactive. An audit ACE added to an SACL after some accesses have happened does not generate events for those past accesses. Auditing observes the present; the past is the past.
- Audit does not survive crash. An event in flight when the kernel crashes is lost. KMES is designed for the typical case where the kernel keeps running; for crash-resilient audit, a userspace consumer that persists events to disk is what provides durability.
- Audit does not authenticate. The recorded subject is whatever the calling token says they are. If the token has been issued correctly, the audit record reflects reality. The audit layer does not independently verify the principal's identity beyond what the token provides.
Where audit lives #
A handful of components are involved in producing and consuming audit:
- The kernel runs the audit walk during AccessCheck and produces the events.
- KMES transports the events from the kernel to userspace.
eventdis the userspace daemon that subscribes to KMES and writes events to wherever they should go (a local file, a remote log collector, a SIEM endpoint).- Audit consumers are tools that read what
eventdhas captured — query interfaces, dashboards, alerting systems.
This separation is what lets the kernel stay simple. The kernel's only job is generating events; everything else — buffering, persistence, querying, alerting — is userspace. A failure in the audit-storage layer does not affect the access check; the access decision happens regardless of whether the corresponding event was ever recorded.
Auditing is best-effort #
The audit layer aims to record every meaningful event, but it does not guarantee that every event is recorded. The reasons:
- An event in flight when the kernel crashes is lost.
- A consumer that has fallen behind may have its KMES backlog dropped (KMES's flow control is a per-consumer concern; the kernel does not block the access check on consumer back-pressure).
- A misconfigured SACL or
audit_policymay simply fail to generate the events that would have been useful for forensics.
Best-effort means audit is a signal, not a proof. A deployment that needs cryptographic non-repudiation of every access would need additional infrastructure on top of the audit layer. For the typical use cases — compliance, alerting, incident response — best-effort is enough.
The model deliberately puts performance ahead of guaranteed delivery. An access check that has to block until its audit event has been written to disk would be intolerably slow. The kernel chooses to record what it can and not slow down the actual work; the userspace audit pipeline is responsible for getting the events from the transport buffer to whatever destination they need to land at.
Where to start #
If you want to understand audit ACEs — how they work, the difference between standard (SYSTEM_AUDIT) and continuous (SYSTEM_ALARM) ACEs, audit polarity, and the conditional audit case — read Audit ACEs.
If you want privilege-use audit and the token-forced audit policy — events that fire independent of any SACL ACE — read Policy-forced auditing.
If you want the wire-level details — event schemas, what each field means, how KMES delivers events to consumers — read Events and transport.
Audit ACEs
Peios / Peios Security Fundamentals / Auditing
The primary mechanism for object-access auditing is audit ACEs in an object's SACL. An audit ACE says: when a specific principal (or principals matching a SID) attempts specific access rights, fire an event. The ACE can be conditioned on whether the attempt succeeded, whether it failed, or both. The event records the access; the access itself is decided by the DACL and the rest of the pipeline.
This page covers the two audit ACE families — SYSTEM_AUDIT* (one-shot at handle creation) and SYSTEM_ALARM* (continuous per-operation) — and the rules that govern when each fires.
SYSTEM_AUDIT — one event per access #
A SYSTEM_AUDIT ACE fires once when an access check completes. The ACE has the standard single-SID structure:
| Field | Meaning |
|---|---|
| AceType | SYSTEM_AUDIT (0x02), or one of the object/callback variants (0x07, 0x0D, 0x0F) |
| AceFlags | Includes SUCCESSFUL_ACCESS_ACE_FLAG (0x40) and/or FAILED_ACCESS_ACE_FLAG (0x80) |
| Mask | The access mask the ACE is interested in |
| SID | The principal the ACE targets |
When an access check is in step 14 (the SACL audit walk), the kernel scans the SACL for audit ACEs. For each SYSTEM_AUDIT ACE, it asks:
- Does the caller's identity match the ACE's SID? If not, the ACE does not apply.
- Does the requested access overlap the ACE's mask? If not, the ACE does not apply.
- If the access succeeded and the ACE has
SUCCESSFUL_ACCESS_ACE_FLAGset, emit an event. - If the access failed and the ACE has
FAILED_ACCESS_ACE_FLAGset, emit an event. - Otherwise the ACE applies but does not fire.
One event per matching ACE. An object with three matching audit ACEs produces three events on a single access. A separate access later — a new handle, a new access check — produces a separate set of events.
The event is for the access check as a whole. It does not fire per-operation on the open handle; once the handle is open, subsequent operations do not re-fire the audit ACE. (For per-operation audit, see SYSTEM_ALARM below.)
Common patterns #
The SUCCESSFUL_ACCESS_ACE_FLAG and FAILED_ACCESS_ACE_FLAG flags can both be set in one ACE:
| Flag combination | Audit behaviour |
|---|---|
SUCCESSFUL_ACCESS_ACE_FLAG only | Audit only when the access succeeded. |
FAILED_ACCESS_ACE_FLAG only | Audit only when the access was denied. |
| Both set | Audit every access attempt, regardless of outcome. |
| Neither set | The ACE applies but never fires. |
The "both set" pattern gives a complete audit log for an object — every attempt, allowed or denied. The "failed only" pattern is useful for security monitoring: alert me whenever someone tries to do X but is refused. The "successful only" pattern is useful for compliance: record everyone who has actually touched the object.
A SACL with no audit ACEs produces no audit events (unless audit_policy on the token fires; see Policy-forced auditing). A SACL with audit ACEs but no relevant flags set is auditing nothing — the ACEs are essentially comments at that point. The flags are what makes them active.
SYSTEM_ALARM — continuous, per-operation #
A SYSTEM_ALARM ACE works differently. It does not fire at access-check time; instead, it configures a continuous audit mask on the open handle, and every subsequent operation through the handle whose required access overlaps that mask produces an event.
The ACE has the same structure as SYSTEM_AUDIT:
| Field | Meaning |
|---|---|
| AceType | SYSTEM_ALARM (0x03), or one of the variants (0x08, 0x0E, 0x10) |
| AceFlags | Same flag bits as audit |
| Mask | The mask to record on the handle |
| SID | The targeted principal |
During step 14 of access check, when a SYSTEM_ALARM ACE matches, the kernel:
- Computes a continuous-audit mask — the union of all matching alarm ACEs' masks.
- Returns the mask to the caller as part of the access check's output.
- The caller (typically FACS for files) stores the mask on the open handle. For registry keys the enforcement point is LCS, the kernel registry subsystem — never the registryd source, which stores data but makes no security decisions (and LCS v0.21 defines only open-time SACL audit for keys, not continuous audit).
Then, on each subsequent operation through the handle, the enforcement point compares the operation's required-access mask against the handle's continuous audit mask. If they overlap (any bit shared), the kernel emits a continuous-audit event for the operation.
Audit vs alarm: granularity #
The difference between SYSTEM_AUDIT and SYSTEM_ALARM is fundamentally about granularity:
| Aspect | SYSTEM_AUDIT | SYSTEM_ALARM |
|---|---|---|
| Event timing | Once, at access-check completion | Per operation on the open handle |
| Event count per handle | One (or one per matching ACE) | Many — one per matching operation |
| What is recorded | The access-check decision | The specific operation that was performed |
| Storage of state | None — fires and forgets | Continuous audit mask on the handle |
| Use case | "Who opened this file with these rights" | "Every read this principal performed on this object" |
For most auditing, SYSTEM_AUDIT is the right tool. The event volume is bounded by the number of distinct accesses, which is usually small.
For high-sensitivity objects where every operation matters — auditing a credential store's every read, recording every write to a security-relevant log file — SYSTEM_ALARM is the right tool. The event volume is bounded by operation count, which can be high, but the visibility into what is actually being done is also higher.
Both can coexist on the same SACL. A SACL with both SYSTEM_AUDIT and SYSTEM_ALARM produces an at-open event from the audit ACE and per-operation events from the alarm ACE.
Audit polarity for SID matching #
A subtle but important rule: audit ACEs match SIDs with deny polarity rather than allow polarity.
For an ACCESS_ALLOWED ACE in a DACL, a group on the token matches only if it has SE_GROUP_ENABLED set. Groups marked SE_GROUP_USE_FOR_DENY_ONLY are invisible to allow ACEs.
For an ACCESS_DENIED ACE, both regular groups and deny-only groups match. The broader view exists so denies cannot be sidestepped by demoting a group to deny-only.
Audit ACEs use the same broad view as deny ACEs. A token's deny-only groups match audit ACEs. Logon SIDs, disabled groups in some cases, every SID associated with the token contributes to audit matching.
The reasoning: the purpose of auditing is to capture the most complete picture of the identity making the access. A token that has been restricted (groups marked deny-only, say) is still that token, with that identity, and the audit log should reflect that. Limiting audit to the narrower allow-polarity view would let auditing miss accesses by tokens that had been deliberately downgraded.
Practical effect: if you write an audit ACE matching a specific group SID, it will fire whether the calling token currently has that group enabled, disabled, or marked deny-only. The audit ACE catches the broadest possible identity match.
Conditional audit ACEs #
The callback variants of audit and alarm ACEs (SYSTEM_AUDIT_CALLBACK, SYSTEM_AUDIT_CALLBACK_OBJECT, SYSTEM_ALARM_CALLBACK, SYSTEM_ALARM_CALLBACK_OBJECT) add a conditional expression. The ACE fires only when the expression evaluates appropriately for an audit.
The rule: an audit-callback ACE fires when its expression evaluates to TRUE or UNKNOWN. It does not fire on FALSE.
This is the opposite of the rule for ACCESS_ALLOWED_CALLBACK (where TRUE fires the allow, UNKNOWN does not) and matches the rule for ACCESS_DENIED_CALLBACK (TRUE or UNKNOWN fires the deny).
The reasoning: an audit ACE's job is to record events. Erring on the side of recording — including when the conditional cannot be definitively evaluated — produces a more complete audit log. A missed event is more dangerous than an extra one. UNKNOWN, therefore, fires the audit.
Practical pattern: an audit ACE conditioned on "the request came from outside the trusted network" — @Local.Source != "internal" — fires on accesses where the local-claims do not include a Source value (the expression evaluates UNKNOWN). The audit log captures the access even though the condition could not be evaluated; an investigator reviewing the log can decide whether the missing claim is meaningful.
Object-scoped audit ACEs #
The object variants (SYSTEM_AUDIT_OBJECT, etc.) carry one or two GUIDs that scope the ACE to specific properties of a directory-style object. The audit fires only when the access is to a property whose GUID matches.
For most objects (files, registry keys, processes), object ACEs are not used and the basic single-SID variants are what appears in SACLs. Object ACEs come up in directory-style objects where per-property audit is meaningful — auditing "who read the manager property of this user object" rather than just "who read this user object".
The mechanics of object ACEs are the same as elsewhere — the GUID-scoped match — and are covered in ACLs, ACEs, and access masks. The audit case is identical to the access-check case structurally; only the firing semantics differ.
Inherit-only audit ACEs #
An audit or alarm ACE with INHERIT_ONLY_ACE set does not fire on the object it sits on. It is for inheritance to children only — when a child is created, the audit ACE is copied to the child's SACL (subject to inheritance flags) and from then on fires on the child.
This is the same inheritance semantics that DACL ACEs use. An inherit-only audit ACE on a parent directory does not audit accesses to the parent; it just propagates to child files.
What audit ACEs do not produce #
A few clarifications:
- They do not skip pre-decided accesses. If MIC or PIP pre-decided write bits as denied at step 5, and the access check ends with the write denied, the audit walk still runs and audit ACEs fire normally. The pre-decision does not skip the audit walk.
- They do not produce duplicate events on the same handle for the same access. An access check produces one event per matching ACE. If a process accesses the same object twice (two separate opens, two access checks), each access check produces its own set of events.
- They do not record per-operation activity on an open handle. For per-operation events, alarm ACEs are the mechanism. Standard audit ACEs are "at the moment of opening, was this access allowed?".
- They do not affect access. The audit walk is observational. A matching audit ACE does not change what gets granted, even if the audit ACE has a different mask than the matching DACL ACE.
CAAP effective SACLs #
A SYSTEM_SCOPED_POLICY_ID_ACE in the object's SACL references a central access policy. That policy can have its own effective SACL containing audit ACEs (see Central access policies). The policy's audit ACEs are collected during CAAP evaluation at step 12 and merged into the audit walk at step 14.
From the audit consumer's perspective, an event triggered by a CAAP's effective SACL looks the same as one triggered by the object's own SACL. The event records what fired, and where the matched ACE came from is part of the event metadata, but the structure is uniform.
Practical implication: a policy administrator who wants to audit a class of objects can put the audit ACE in the policy's effective SACL once, and every object referencing the policy contributes audit events — better than putting a copy of the audit ACE on every object's SACL individually.
Where to go next #
For the events that fire without any SACL ACE — privilege-use audit and the token's audit_policy — read Policy-forced auditing.
For what the fired events contain and how they reach userspace, read Events and transport.
For where audit ACEs live and who may read or write them, read The SACL.
Policy-forced auditing
Peios / Peios Security Fundamentals / Auditing
Not every audit event comes from an audit ACE in an SACL. Two mechanisms produce events that fire regardless of whether an SACL ACE matched: privilege-use audit (fires when a privilege contributes bits to the granted mask) and the token's audit_policy bitmask (forces object-access or privilege-use events whenever the corresponding flag is set on the token).
Both mechanisms are forced by something other than an SACL ACE — by the privilege exercise itself, or by the token's per-principal audit policy. They run during the same audit walk as SACL ACEs but are independent of any specific ACE matching.
This page covers both mechanisms, when each fires, and how they compose with SACL-driven audit.
Privilege-use audit #
When AccessCheck completes, the pipeline knows which privileges contributed bits to the granted mask. A backup tool that used SeBackup to bypass the DACL has the privilege recorded against the bits it contributed; an administrator who used SeTakeOwnership to gain WRITE_OWNER has that privilege recorded.
If the calling token's audit_policy requests it, the kernel fires a privilege-use event for each privilege that contributed. The events fall into two flavours:
| Flavour | Triggered by | Fires when |
|---|---|---|
| Successful privilege use | audit_policy & PRIVILEGE_USE_SUCCESS (0x04) | A privilege contributed bits AND those bits survived to the final granted mask. |
| Failed privilege use | audit_policy & PRIVILEGE_USE_FAILURE (0x08) | A privilege contributed bits BUT those bits did NOT survive (stripped by a later layer like confinement or CAAP). |
The "failed" case is the interesting one. A privilege that fires but does not actually grant access tells the audit log that the caller attempted to exercise a privilege and was prevented by some narrowing layer. This is observable separately from a plain "access denied" — the privilege got far enough to fire and then was stripped.
A worked example. A token with SeBackupPrivilege enabled tries to read a confined object. The pipeline:
- Step 4: SeBackup grants read-category bits via the privilege.
- Step 8: DACL walk grants nothing additional.
- Step 11: confinement intersection strips the privilege-granted bits (confinement does not preserve privilege grants).
- Final granted mask: empty.
If audit_policy & PRIVILEGE_USE_SUCCESS is set, no event fires (no bits survived). If audit_policy & PRIVILEGE_USE_FAILURE is set, a failed-use event fires, recording "SeBackupPrivilege contributed FILE_READ_DATA but the bits were stripped".
Either configuration is useful. Tracking only success tells you when privileges actually grant access; tracking only failure tells you when privileges try to grant access but cannot. Tracking both gives you a complete picture of privilege exercise.
The event itself includes the privilege name (e.g. SeBackupPrivilege), the bits the privilege contributed pre-narrowing, the bits that survived to the final result, and a success boolean. The exact schema is in Events and transport.
Token audit_policy #
The token's audit_policy field is a small bitmask with four flags:
| Flag | Value | Effect |
|---|---|---|
OBJECT_ACCESS_SUCCESS | 0x01 | Force an audit event on every successful access by this token, regardless of SACL ACE matches. |
OBJECT_ACCESS_FAILURE | 0x02 | Force an audit event on every failed access by this token. |
PRIVILEGE_USE_SUCCESS | 0x04 | Emit successful privilege-use events. |
PRIVILEGE_USE_FAILURE | 0x08 | Emit failed privilege-use events. |
PRIVILEGE_USE_* controls privilege-use audit (above). OBJECT_ACCESS_* controls a separate kind of force: object-access events that fire even when no SACL audit ACE matched.
How OBJECT_ACCESS_SUCCESS / FAILURE work #
When the calling token's audit_policy has OBJECT_ACCESS_SUCCESS set, step 14b of the pipeline fires an object-access event for every access by this token where the granted mask is non-empty — regardless of whether any SACL audit ACE would have matched.
When OBJECT_ACCESS_FAILURE is set, an event fires for every access where the granted mask did not satisfy the requested mask (i.e. some requested rights were denied).
These flags effectively say: "audit every access this principal makes". The audit log gets an event whether or not the object's owner thought to put an audit ACE in the SACL.
The use case: surveillance of specific principals. A security team responding to a suspected compromised account can set OBJECT_ACCESS_SUCCESS | OBJECT_ACCESS_FAILURE on that account's tokens (via authd reissuing the token) and capture every access the account makes. The objects' SACLs do not need to be modified; the audit comes from the token policy.
Another use case: high-sensitivity contractors or third parties who need temporary access. Their tokens are issued with OBJECT_ACCESS_* flags set so every access is logged independently of which specific objects they touch.
Setting audit_policy #
The audit_policy field is set at token creation and is immutable for the lifetime of the token. authd specifies the value when minting the token via kacs_create_token. There is no AdjustPrivileges-style call to modify audit_policy at runtime.
This is consistent with the rest of the immutable token state. A token's audit policy is decided when it is issued; changing it requires a new token.
For administrators wanting to toggle audit policy on a principal, the mechanism is: have authd reissue the principal's tokens with the new policy. Existing sessions continue to operate under the old policy; new sessions get the new policy. (For more immediate effect, revoke the existing sessions and force re-authentication.)
Composition with SACL audit #
The SACL audit walk (step 14) and the token audit policy (step 14b) run independently. Both can fire events for the same access.
A worked composition example. A SACL has one audit ACE on Everyone for FILE_READ_DATA with SUCCESSFUL_ACCESS_ACE_FLAG. The calling token has audit_policy = OBJECT_ACCESS_SUCCESS. An access for read succeeds.
- Step 14 (SACL walk): the audit ACE matches Everyone, the access succeeded, the success flag is set, fire one event with trigger = "sacl" and the matched ACE.
- Step 14b (token-forced):
OBJECT_ACCESS_SUCCESSis set, the access succeeded, fire one event with trigger = "policy".
Two events for one access. They are not duplicates — they have different triggers, and an audit consumer can distinguish them. The SACL-driven event records that this specific audit ACE matched; the policy-driven event records that the token's audit policy required logging.
In practice, audit consumers either treat both events as one entry (deduplicating by some criteria like access context) or keep them separate. The kernel does not merge them; both fire.
What policy-forced auditing does not do #
A few clarifications:
- Token audit_policy does not affect the access decision. The flags fire events; they do not gate access. A token with
OBJECT_ACCESS_FAILUREset is not somehow more likely to be denied; the access check runs identically. - Token audit_policy does not fire on every kernel surface. It fires on AccessCheck-mediated accesses. Operations that do not go through AccessCheck (a syscall the kernel handles directly without an SD lookup, an internal kernel operation) are not subject to it. Privilege-use audit fires only on privileges that fire during AccessCheck, not on privileges exercised in kernel-standalone paths.
- Privilege-use audit does not always fire. It fires only when the token's
audit_policyhas the corresponding bit set. A token withaudit_policy = 0produces no privilege-use events, even when privileges are exercised. The policy is opt-in per token.
The four bits in detail #
A summary table of what each bit controls:
| Bit | Controls | When event fires |
|---|---|---|
OBJECT_ACCESS_SUCCESS (0x01) | Successful object access | After step 14b, when granted mask is non-empty |
OBJECT_ACCESS_FAILURE (0x02) | Failed object access | After step 14b, when requested mask is not fully granted |
PRIVILEGE_USE_SUCCESS (0x04) | Privilege use that succeeded | After step 13, when a privilege's contributed bits survived |
PRIVILEGE_USE_FAILURE (0x08) | Privilege use that failed | After step 13, when a privilege's contributed bits were stripped |
The bits are independent. A token can have all four, none, or any combination. The most common configurations:
audit_policy | Use case |
|---|---|
0 | Default. Audit only from SACL ACEs. |
OBJECT_ACCESS_FAILURE | PRIVILEGE_USE_FAILURE | Capture every denial. Useful for security monitoring. |
OBJECT_ACCESS_SUCCESS | OBJECT_ACCESS_FAILURE | Surveillance — every access by this principal. |
| All four | Maximum visibility. Compliance audit, forensics. |
The choice is operational: how much audit volume can the deployment afford to handle, and what is the audit pipeline configured to do with the events. There is no security benefit to setting more bits than the consumer can actually process — events that are dropped because the consumer fell behind are not useful audit.
Putting it together #
The full set of auditing channels:
| Source | Fires when |
|---|---|
SYSTEM_AUDIT* ACE in SACL | The ACE matches the caller's identity and access |
SYSTEM_ALARM* ACE in SACL | Per-operation on an open handle when the operation overlaps the ACE's mask |
| CAAP effective SACL audit ACE | The CAAP rule applied and its audit ACE matched |
PRIVILEGE_USE_SUCCESS audit policy | A privilege contributed surviving bits to the granted mask |
PRIVILEGE_USE_FAILURE audit policy | A privilege contributed bits that were stripped by a later layer |
OBJECT_ACCESS_SUCCESS audit policy | The access succeeded (granted mask non-empty), no SACL ACE match required |
OBJECT_ACCESS_FAILURE audit policy | The access failed (some requested bits denied), no SACL ACE match required |
Plus the session-destruction event (covered in Logon sessions) which fires independently of any access check.
A single access can produce events from many of these channels at once. An audit consumer sees them, deduplicates or correlates as appropriate, and persists what is needed for downstream use.
Where to go next #
For the schemas of the events these mechanisms emit and how they reach consumers, read Events and transport.
For the privilege model behind privilege-use audit, read Privileges.
Events and transport
Peios / Peios Security Fundamentals / Auditing
The audit layer is the part of the access check that generates events. Once an event has been produced, its further life is the transport layer's concern — getting the event from the kernel to whatever userspace consumer is interested. Peios uses KMES (Kernel Message Event Stream) for this. The kernel writes events to KMES; userspace daemons subscribe and receive them; eventually they are written to wherever the deployment's audit pipeline wants them.
This page covers the event schemas — what each event type contains — and the transport mechanics at a conceptual level.
The event types #
Five event types come out of the access-check layer and its surroundings:
| Event type | When it fires | Fired by |
|---|---|---|
access-audit | An SACL audit ACE matched, or a token audit_policy OBJECT_ACCESS_* flag forced it | Step 14 and 14b of AccessCheck |
continuous-audit | An operation on an open handle matched the handle's continuous audit mask | The kernel enforcement point for the operation (FACS for file handles; other kernel subsystems such as LCS for their own objects) |
privilege-use | A privilege contributed bits AND the token's audit_policy PRIVILEGE_USE_* requested it | Step 13 of AccessCheck |
caap-policy-diagnostic | A CAAP SACL evaluation error, or a staged/effective result mismatch | Step 12 of AccessCheck |
logon-session-destroyed | A logon session lost its last token reference | Session destruction path (independent of access check) |
Each event is a self-describing record. Consumers do not need to know which check produced an event in order to parse it; the event itself carries enough metadata to identify itself and its context.
Event encoding #
All audit events are encoded as msgpack maps with UTF-8 string keys. msgpack is the binary serialisation format Peios uses across kernel/userspace boundaries; it produces compact records that are quick to parse and round-trip cleanly.
SIDs and ACEs appear as bin (binary blob) values in the msgpack — the same binary forms used in the kernel. UIDs, timestamps, and bitmasks appear as uint values. Strings appear as msgpack strings (UTF-8).
Consumers are expected to ignore unknown keys. Future kernel versions may add fields to event records without changing the existing ones. A consumer that processes only the fields it knows about and ignores the rest will continue to work across kernel upgrades.
The msgpack format and the key conventions are stable across the v0.20 line. The exact byte-level layout is documented in Wire formats reference; this page covers the logical structure.
The subject record #
A subject record appears in every event that involves a calling principal. It identifies the token under which the operation ran:
| Field | Type | Meaning |
|---|---|---|
user_sid | bin | The token's user SID. |
group_sids | array of bin | The token's group SIDs (with attributes, in the same SID_AND_ATTRIBUTES form the token holds). |
integrity_level | uint | The token's integrity level (one of the well-known integrity RIDs). |
pip_type | uint | The PIP type of the calling process. |
pip_trust | uint | The PIP trust level of the calling process. |
For events that fire from inside an impersonating thread, the subject reflects the effective token — the impersonation token — not the primary. This is what the access check ran against, and it is what should be recorded.
For continuous-audit events specifically, the subject is the effective token at the moment of the operation, not at the moment the handle was opened. A process whose effective token has changed since opening the handle gets the up-to-date subject in each continuous-audit event.
The process record #
A process record identifies the kernel-level context the event came from:
| Field | Type | Meaning |
|---|---|---|
pid | uint | Process ID. |
name | string | Process executable name. |
executable_path | string | Full path to the executable. |
These fields let a consumer correlate the event with a running (or recently-running) process. The pid is useful in the short term — the process may still exist when the consumer is processing the event. The name and path are useful for human-readable correlation and for long-term records where the pid may have been reused.
access-audit event schema #
The most common event type. Fired by step 14 (SACL audit walk) and step 14b (token audit_policy).
| Field | Type | Meaning |
|---|---|---|
subject | map | The subject record. |
object_context | bin or nil | Caller-supplied opaque object identifier. nil if not provided. |
requested_access | uint | The access mask the caller requested (after generic mapping). |
granted_access | uint | The mask of rights actually granted. |
success | bool | Whether the access succeeded (granted contains all of requested). |
trigger | map | Trigger record — see below. |
process | map | The process record. |
The trigger map identifies why this event fired:
| Field | Type | Meaning |
|---|---|---|
kind | string | Either "sacl" (a SACL audit ACE matched) or "policy" (token audit_policy forced it). |
ace | bin or nil | For "sacl" triggers, the matched ACE bytes. For "policy" triggers, nil. |
A consumer can use trigger.kind to filter: events from SACL ACEs versus events forced by token policy. The ace field for SACL triggers includes the bytes of the matched ACE so a consumer can reconstruct what specific rule produced the event.
continuous-audit event schema #
Fired per-operation on a handle whose continuous audit mask overlaps the operation's required mask.
| Field | Type | Meaning |
|---|---|---|
subject | map | The subject record (operation-time effective token, not handle-open token). |
object_context | bin or nil | Object identifier, if retained by the enforcement point. |
operation | string | The operation name. For FACS operations, prefixed with file. (e.g. file.read, file.write). |
requested_access | uint | The required mask for this operation. |
matched_access | uint | The subset of required_access that overlapped the handle's continuous audit mask. |
granted_access | uint | The mask cached on the handle at the time of the operation. |
success | bool | Whether the operation succeeded. |
process | map | The process record. |
The two access-mask fields require explanation:
requested_accessis what this operation asks for. Areadon a file requires read; anmmaprequires execute or write depending on mode.matched_accessis the subset ofrequested_accessthat intersected the handle's continuous audit mask. The event fires because of this overlap; the mask is recorded so consumers know which alarm ACEs were relevant.granted_accessis the cached access mask on the handle from the original open. Operations can only succeed if their required mask is a subset of the cached granted mask, so this field tells consumers what the handle was actually authorised for.
privilege-use event schema #
Fired at step 13 of AccessCheck for each privilege that contributed bits.
| Field | Type | Meaning |
|---|---|---|
subject | map | The subject record. |
object_context | bin or nil | Object identifier (the same one the access check was for). |
privilege | string | The canonical privilege name (e.g. SeBackupPrivilege). |
requested_access | uint | The bits the caller requested that this privilege might address. |
granted_access | uint | The bits the privilege actually contributed. |
surviving_access | uint | The subset of granted_access that survived to the final granted mask. |
success | bool | True if surviving_access is non-empty (the privilege contributed bits that made it through). |
process | map | The process record. |
The three access masks tell the full story: what the caller wanted, what the privilege tried to grant, what actually survived. A successful privilege-use event has a non-empty surviving_access; a failed event has surviving_access == 0 — the privilege tried to grant bits but they were stripped.
logon-session-destroyed event schema #
Fired when a logon session loses its last token reference and is destroyed.
| Field | Type | Meaning |
|---|---|---|
session_id | uint | The destroyed session's LUID. |
user_sid | bin | The user SID the session belonged to. |
logon_type | uint | The session's logon type. |
auth_package | string | The auth-package name (e.g. "Kerberos", "NTLM", "local"). |
created_at | uint | The session's creation timestamp. |
No subject record (the subject was this session). No process record (the session is identified by its ID, not by any specific process).
The event lets userspace consumers — notably authd — release session-scoped state (Kerberos tickets, cached directory data, per-session credentials).
KMES transport #
The kernel does not directly write audit events to disk, send them over a network, or hand them to a specific userspace process. The kernel writes events to KMES — Kernel Message Event Stream — which is a per-subscriber ring buffer with kernel-side production and userspace-side consumption.
Conceptually:
- The kernel produces an event (e.g. an audit fires during step 14).
- The kernel writes the event into each subscriber's KMES buffer.
- Userspace consumers read from their KMES buffers when they are ready.
- Once a buffer is drained, the kernel can continue producing into it.
The buffers are per-subscriber. A subscriber that falls behind has its own buffer fill up; eventually KMES applies flow control to that buffer (typically dropping the oldest events). The kernel's production of events to other subscribers is unaffected.
The audit subsystem typically has at least one subscriber: eventd, the userspace audit daemon. eventd subscribes to the audit event types, reads them from KMES, and writes them to wherever the deployment's audit pipeline goes (a local file, a remote syslog, a SIEM collector). Other subscribers — debugging tools, real-time monitors — can also exist.
The exact KMES protocol, buffer sizes, flow-control mechanics, and reliability guarantees are outside the scope of this auditing topic; they are defined in PSPK §2, the kernel-boundary protocol standard in which KMES is the core abstraction. For auditing purposes, what matters is:
- Events are not retained by the kernel beyond writing them to KMES.
- A subscriber that falls behind may lose events.
- Reliable persistence is the job of whoever drains the KMES buffer, not the kernel.
What consumers should look at #
For someone writing or operating an audit consumer:
- Filter by event type. Most consumers care about specific types (just
access-auditfor compliance, justprivilege-usefor privilege monitoring, etc.). Filtering early reduces processing volume. - Correlate by subject + object_context. A consumer that wants to track "everything user X did to object Y" should index events by these two fields.
- Use the trigger field to distinguish event sources. An
access-auditwith trigger "policy" was forced by token audit policy; one with trigger "sacl" came from a specific ACE. The distinction matters for some compliance scenarios. - Be tolerant of new fields. Future kernel versions may add fields to event records. Ignoring unknown keys is the rule; rejecting events with unfamiliar fields is a bug.
- Plan for event loss. A KMES subscriber that falls behind loses events. Consumers that need lossless audit must implement their own persistence layer above KMES and handle backpressure appropriately.
What the audit transport does not provide #
A few clarifications:
- No reliable delivery to the kernel's edge. Events written to KMES are best-effort; a crashing subscriber loses its buffer. The kernel does not block access checks on subscriber readiness.
- No replay. A consumer that joins late, or one that loses its connection, cannot ask for old events. The events are produced once; missing them means missing them.
- No event correlation across boots. Each boot starts fresh. Audit logs that span reboots are assembled by userspace persistence (eventd writing to disk, log aggregators collecting across systems).
- No authentication of events at the consumer. Events are produced by the kernel and received by KMES subscribers. There is no signature on events; the trust model is "the kernel said this, so it is true". A subscriber that needs cryptographic non-repudiation of events would need to add a signing layer in userspace.
The model is built for performance and simplicity over absolute guarantees. For deployments that need stronger properties, the userspace audit pipeline is where additional layers belong — not in the kernel-to-KMES boundary.
See also #
- Auditing — the model these events come from.
- Audit ACEs — the SACL mechanisms behind access-audit and continuous-audit events.
- Policy-forced auditing — the token policy behind privilege-use and forced object-access events.
- Wire formats reference — byte-level encoding of the SIDs and ACEs carried in events.
Inspecting security state
Peios / Peios Security Fundamentals / Inspecting security state
When something goes wrong with access control, the answer is almost always in the live kernel state — what's on the relevant token, what's on the object's SD, what session the calling thread belongs to. Static configuration (the directory, the registry) tells you what should be the case; the live kernel state tells you what is the case. Inspection is how you read it.
Peios exposes inspection through a handful of surfaces: pseudo-files under /proc for per-process tokens, securityfs entries under /sys/kernel/security/kacs/ for the calling thread and the active sessions, and the KACS_IOC_QUERY ioctl on any token fd for the full menu of structured queries. This page maps the surfaces and the access rules they share; later pages in this topic cover each in depth.
What you can inspect #
Three classes of state are inspectable:
| State | Lives in | Primary inspection surface |
|---|---|---|
| Tokens | Per-thread (primary or effective), reference-counted in the kernel | /proc/<pid>/token, /proc/<pid>/task/<tid>/token, /sys/kernel/security/kacs/self, KACS_IOC_QUERY on a token fd |
| Logon sessions | Per-authentication-event, referenced by every token via auth_id | /sys/kernel/security/kacs/sessions (text listing); TokenStatistics query on a token (to get the session ID) |
| Processes | Per-process, PSB plus process SD plus token references | The process's token fd (for token state); querying the PSB (for PIP, mitigations); reading the process SD via kacs_get_sd |
The fourth thing one might expect — inspecting security descriptors on arbitrary objects (files, registry keys) — is covered by kacs_get_sd, the read counterpart of kacs_set_sd. That surface is part of the file-access and registry-access topics, not this one. This topic covers inspection of identity-and-process state specifically.
What you cannot inspect #
A few things that are not inspectable through this topic's surfaces:
- Per-thread state for threads other than the inspection target. The token fd you get from
/proc/<pid>/tokenis the primary token (a process-wide property). For thread-specific impersonation state, you need the thread-specific path/proc/<pid>/task/<tid>/token. - Internal kernel state. Reference counts, internal locks, cache state — these are not exposed. The inspection surfaces expose user-meaningful state only.
- Historical state. A token that has been destroyed is gone; its fields cannot be recovered. The kernel does not retain a history of tokens. For historical analysis you need the audit log.
- Other principals' state without authority. Reading another process's token requires
PROCESS_QUERY_INFORMATIONon the process plus PIP dominance plus the token access rights. The surfaces enforce all three. There is no "global view" available to a non-privileged caller.
Who can inspect what #
The access rules for inspection mirror the access rules for the underlying state. The kernel does not have a separate "inspection" privilege; reading state requires the same rights that any other read of that state would require.
For your own state (your thread's effective token, your process's primary token, the sessions you are part of):
/proc/self/token,/proc/self/task/<self-tid>/token— always readable./sys/kernel/security/kacs/self— always readable.- Token fd from
kacs_open_self_token— always returnable to you.
For another process's state:
/proc/<pid>/tokenrequiresPROCESS_QUERY_INFORMATIONon the target process plus PIP dominance./proc/<pid>/task/<tid>/tokenrequires the same.- Token fd from
kacs_open_process_tokenrequires the same plus the appropriate token rights.
For session state:
/sys/kernel/security/kacs/sessionsrequiresBUILTIN\AdministratorsorSYSTEM(enforced by the SD on the securityfs file).- Individual session details accessible via tokens you can already read (your own, or others' subject to the above rules).
For process state beyond the token (process SD, PIP, mitigations):
- Read your own PSB via
kacs_open_self_tokenor querying through the process-token's interface — always. - Read another process's PSB requires
PROCESS_QUERY_INFORMATIONplus PIP dominance.
The pattern: self is free, others need standard cross-process authority. PIP dominance is the absolute ceiling that nothing — no privilege, no inspection surface — bypasses. The kernel does not expose state of higher-trust processes to lower-trust callers under any conditions.
Two ways to read a token #
The kernel exposes two complementary ways to read what a token holds:
- Get a token fd, then query via
KACS_IOC_QUERY. This is the structured API. The ioctl takes a query class (one of 24 numbered classes) and returns binary data structured according to that class. Suitable for programmatic inspection. - Read a pseudo-file under
/procor/sys/kernel/security/kacs/. Some of these expose token fds (open them with O_PATH semantics and pass to ioctls); others expose text formats suitable for direct reading.
Both routes converge on the same kernel state. The pseudo-file route is convenient for shell-level inspection; the ioctl route is the right tool for programmatic use. A monitoring tool will typically use the ioctl; a sysadmin debugging an issue at a terminal will typically cat a pseudo-file.
The pseudo-files under /proc/<pid>/token are not text — they are token fds. cat-ing them does not produce human-readable output. The way to use them from the shell is the token command, which knows how to interpret a token fd and render its contents as readable text.
The /sys/kernel/security/kacs/sessions file is a text format — designed to be cat-able. It is the exception; the other surfaces are binary.
Standard query patterns #
A handful of patterns come up repeatedly:
- "Who is this thread?" — Open
/proc/<pid>/task/<tid>/token, query classTokenUserto get the user SID. - "What groups are on this token?" — Open the token, query the groups class.
- "What privileges are enabled?" — Open the token, query the privileges class.
- "Which session does this process belong to?" — Open the process's primary token, query
TokenStatisticsto get theauth_id, then look up that ID in/sys/kernel/security/kacs/sessions. - "Who owns this session?" — Either query
TokenStatistics(returns the auth_id) and look up the session in the listing, or read the listing directly and find the session by its details. - "What PIP level is this process at?" — Read the PSB through the process's token-related surfaces.
The two-step lookup for sessions (token → auth_id → sessions listing) is the standard pattern. Tokens know which session they belong to via auth_id; the session listing has the human-readable details (logon type, auth package, user SID, creation time).
Where to start #
If you want to inspect a token — what fields it has, how to query each one, the two-call pattern for variable-length data — read Inspecting tokens.
If you want to inspect a session — the text listing format, how to find which tokens belong to a session, how to track session lifecycle — read Inspecting sessions.
If you want to inspect a process — the process SD, the PSB's PIP and mitigation fields, the rules for cross-process inspection — read Inspecting processes.
If you want to inspect the audit event stream rather than static state — the live flow of events as access checks fire — read The event stream. That page covers revstrm, a low-level diagnostic probe that taps the raw KMES stream directly. It is a debugging tool for the audit pipeline, not the everyday way to view events (that is eventd's job).
If you have a denial in front of you and want a systematic walk through the diagnosis, Debugging a denial is the right page.
Inspecting tokens
Peios / Peios Security Fundamentals / Inspecting security state
A token's fields are read through the KACS_IOC_QUERY ioctl on a token fd. The ioctl takes a query class — a small integer naming what to return — and returns the corresponding data structured per that class. There are 24 defined classes covering everything from "the user SID" to "the full set of user and device claims".
This page covers how to obtain a token fd, the query ioctl mechanics, the two-call pattern for queries with variable-length output, and an overview of the class catalog.
Obtaining a token fd #
Token fds come from a handful of syscalls and pseudo-files:
| Source | Returns | Access check |
|---|---|---|
kacs_open_self_token | The calling thread's effective token (or primary, with KACS_REAL_TOKEN flag) | None — always succeeds |
kacs_open_process_token(pidfd) | A target process's primary token | PROCESS_QUERY_INFORMATION + PIP dominance + token SD rights |
kacs_open_thread_token(tid) | A specific thread's effective token | Same as above |
kacs_open_peer_token(sock_fd) | The peer's captured identity on a connected Unix socket | None beyond the connection itself |
/proc/<pid>/token | The primary token of process <pid> | PROCESS_QUERY_INFORMATION + PIP dominance |
/proc/<pid>/task/<tid>/token | The effective token of thread <tid> in process <pid> | Same |
/sys/kernel/security/kacs/self | The calling thread's effective token | None — always readable |
The fds carry an access mask. The mask is what the kernel granted at open time and what the subsequent ioctl will check against. A fd opened with TOKEN_QUERY cannot be used to install or duplicate the token; the ioctl will see the request as exceeding the fd's mask and refuse.
The pseudo-files under /proc and /sys/kernel/security/kacs/ return read-only fds — they carry TOKEN_QUERY and nothing else. To get a fd with more access you need one of the syscalls.
KACS_IOC_QUERY #
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.
Query class catalog #
There are 24 defined query classes. Each returns a structured payload defined for that class. The most commonly used:
| Class | Returns |
|---|---|
TokenUser | The token's user_sid and its attributes. |
TokenGroups | The groups array — every group SID with its attributes. Variable length. |
TokenPrivileges | The four privilege bitmasks — present, enabled, enabled-by-default, used. |
TokenOwner | The default owner SID. |
TokenPrimaryGroup | The default primary group SID. |
TokenDefaultDacl | The token's default DACL. Variable length. |
TokenSource | The source name and source-LUID identifying who minted the token. |
TokenType | Primary or Impersonation. |
TokenImpersonationLevel | Anonymous / Identification / Impersonation / Delegation (Primary tokens return Anonymous). |
TokenStatistics | token_id, auth_id (the logon-session ID), modified_id, token type, and expiry. |
TokenRestrictedSids | The restricted_sids array. Variable length. |
TokenSessionId | The interactive session ID. |
TokenOrigin | The originating logon-session ID. |
TokenElevationType | Default / Full / Limited. |
TokenIntegrityLevel | The integrity SID. |
TokenMandatoryPolicy | The mandatory_policy flags (NO_WRITE_UP, NEW_PROCESS_MIN). |
TokenLogonType | How the token's logon session was created — interactive, network, batch, service, and so on. |
TokenLogonSid | The logon session's logon SID (S-1-5-5-X-Y). |
TokenAppContainerSid | The confinement SID. Empty if the token is not confined. |
TokenCapabilities | The confinement capability SIDs with their attributes. Variable length. |
The remaining classes are TokenDeviceGroups (the device group SIDs), TokenUserClaims and TokenDeviceClaims (the claim arrays evaluated by conditional ACEs), and TokenProjectedSupplementaryGids (the token's projected Linux supplementary GIDs). Note there is no query class for the partner of a linked token pair — that goes through a separate ioctl, KACS_IOC_GET_LINKED_TOKEN, with its own access rules.
Each class's exact byte-level payload format is in the Wire formats reference; this page covers what each class is for.
Patterns by use case #
A handful of patterns come up repeatedly:
"Who is this thread acting as?" Open the thread's effective token (/proc/<pid>/task/<tid>/token or kacs_open_self_token). Query TokenUser to get the principal SID. Optionally query TokenImpersonationLevel to see if this is an impersonation token, and what level.
"What rights does this token have on this object?" This is not a query — you call AccessCheck with the token, the object's SD, and the access mask you want to test. Querying the token alone does not tell you the answer; the rights depend on the SD too.
"Which session does this token belong to?" Query TokenStatistics to get auth_id. Look up that ID in /sys/kernel/security/kacs/sessions for the session's details.
"Is this token elevated?" Query TokenElevationType. If Full, this token is the elevated half of a linked pair. If Default, it is not part of a pair. If Limited, it is the non-elevated half — the elevated counterpart is reachable via KACS_IOC_GET_LINKED_TOKEN.
"What privileges can this token actually exercise?" Query TokenPrivileges and inspect both the present and enabled bitmasks. A privilege is exercisable if it is both present and enabled. A privilege that is present but disabled can be enabled via AdjustPrivileges; a privilege that is absent cannot.
"Has this token been adjusted since I last looked?" Query TokenStatistics. The modified_id field is a counter that increments on every adjustment. If it has changed since your last query, the token has been adjusted.
What query classes do not let you do #
A few clarifications:
- You cannot modify a token through a query class. Queries are read-only. Modification goes through AdjustPrivileges, AdjustGroups, AdjustDefault, or
kacs_set_sd. - You cannot enumerate every token on the system. There is no "list all tokens" call. You can walk
/proc/*/tokento find tokens belonging to currently-running processes, but tokens held only by file descriptors with no associated running process are not enumerable. - You cannot read tokens you do not have authority for. A token fd with only
TOKEN_QUERYlets you query, but the fd had to be opened with appropriate authority. The query ioctl does not bypass the access checks at open time. - You cannot query undefined classes. Class numbers outside the defined range (1–24) return
-EINVAL. There are no hidden or reserved slots — all 24 defined classes are valid.
Reading from the shell #
For a sysadmin debugging at a terminal, the token command is the utility that wraps this ioctl. It handles the two-call pattern, decodes the binary payloads, and renders the results as readable text — so the query classes above become token subcommands rather than raw ioctl calls.
For programmatic use, the ioctl is what you call directly. Language bindings (the C SDK, the Python wrapper) provide ergonomic wrappers but ultimately call the same ioctl.
The pseudo-file approach — /proc/<pid>/token, /sys/kernel/security/kacs/self — gives you the token fd; the actual query still goes through the ioctl. Pseudo-files are just a convenient way to acquire the fd from the shell.
See also #
- Inspecting security state — the topic overview and the shared access rules.
- The token command — the shell wrapper around this ioctl.
- Tokens — what the queried fields mean.
- Wire formats reference — byte-level payload formats for each query class.
Inspecting sessions
Peios / Peios Security Fundamentals / Inspecting security state
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:
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 |
|---|---|---|
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 | Creation timestamp (kernel-internal monotonic units). |
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 file's SD grants read to BUILTIN\Administrators and SYSTEM only. A non-administrative caller will get EACCES on open(). 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.
For a sysadmin running as root (which projects to a token in the administrative group), reading the file is straightforward. For service accounts that need session enumeration capability, the right approach is to grant the relevant SID access via the file's SD, not to weaken the default protection.
Bootstrap sessions #
Two sessions exist before authd is up:
| Session ID | Use |
|---|---|
| 0 | 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 matchingsession_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 logon-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 timestamp — enough to reconstruct what the session was.
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 logon-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. This is what session-revocation tooling does (authd specifically).
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. authd can monitor created_at against a policy maximum and revoke sessions whose age exceeds the limit. Revocation is the userspace-coordinated process described in Session lifecycle: authd walks /proc/*/token, finds tokens with the target auth_id, kills the holding processes.
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 Security Fundamentals / Inspecting security state
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 but the access rules are similar — your own state is always readable; another process's state needs PROCESS_QUERY_INFORMATION plus PIP dominance.
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 (None / Protected / Isolated). |
pip_trust | The PIP trust level within the type. |
| Mitigation flags | The bitfield of enabled mitigations (WXP, LSV, TLP, CFIF, CFIB, PIE, SML, NO_CHILD, etc.). |
security_descriptor | The process SD, governing cross-process operations. |
These are the inspectable fields. Internal fields (refcounts, lock state) are not exposed.
Inspecting your own process #
For a thread inspecting its own process's PSB, the path is:
- Open the process's primary token via
kacs_open_self_token(with theKACS_REAL_TOKENflag if you need the primary specifically, not the impersonation). The returned fd lets you query token state. - Query through the process-related classes —
TokenSessionIdand related — which return PSB-adjacent state where available. - Read the process SD via
kacs_get_sdwith a self-targeted query (using the appropriate flags for "this process").
For some PSB fields, dedicated query routes exist:
- The PIP fields can be read by querying the calling process's PSB through a dedicated path. The typical surface is via the token's session/process classes, which carry the PIP fields as part of the per-token snapshot.
- The mitigation bitfield is readable from the process itself; the typical pattern is to query the PSB directly via the appropriate ioctl.
The exact API for reading the PSB is in the Kernel ABI reference; the conceptual point for this page is that all PSB fields are introspectable by the process itself, with no privilege required.
Inspecting another process's PSB #
To inspect another process's PSB, you need:
PROCESS_QUERY_INFORMATIONon the target's 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 token-bearing 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, the same query mechanisms work: open the target's primary token (via kacs_open_process_token), query through KACS_IOC_QUERY, read the process SD via kacs_get_sd.
The PIP dominance requirement is the same one that gates every cross-process operation. A low-trust caller cannot see into a high-trust process even via inspection. A SeDebugPrivilege-holder can bypass the 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.
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 on the PSB is read via a dedicated query path. For your own process the read is trivial. For another process the same PROCESS_QUERY_INFORMATION + PIP dominance rules apply.
The bitfield 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 the PSB for 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 Security Fundamentals / Inspecting security state
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 cN for an unrecognised class). |
event.type | The event-type string (e.g. access-audit, logon-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 applies a couple of field-name conventions to make raw events readable:
- Keys ending in
sidare rendered as canonical string SIDs (S-1-5-18); keys ending insidsrender as an array of them. - Keys ending in
accessare decoded into|-joined access-right names (e.g.FILE_READ_DATA|FILE_READ_ATTRIBUTES), with any unrecognised bits shown as a hex remainder so nothing is hidden.
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 — access-audit, continuous-audit, privilege-use, logon-session-destroyed — are documented in Events and transport. revstrm does not interpret them beyond the field-name conventions above; it is a stream printer, not an event analyser.
Example #
A short follow session might look like:
14:22:07.481923 cpu0 #10432 KACS access-audit {subject: {user_sid: S-1-5-21-…, integrity_level: 12288, …}, requested_access: FILE_READ_DATA|FILE_READ_ATTRIBUTES, granted_access: FILE_READ_DATA|FILE_READ_ATTRIBUTES, success: true, …}
14:22:07.492010 cpu3 #8871 KACS privilege-use {privilege: "SeBackupPrivilege", surviving_access: FILE_READ_DATA, success: true, …}
14:22:08.003114 cpu0 #10433 KACS logon-session-destroyed {session_id: 4051, user_sid: S-1-5-21-…, logon_type: 2, …}
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.
Boot and trust establishment
Peios / Peios Security Fundamentals / Boot and trust establishment
Boot is the sequence by which Peios goes from "a kernel has been loaded and is starting" to "a fully-running system with real principals, real services, real policy". Most operating systems have a boot sequence, but Peios has a specific trust sequence — the kernel needs to establish how authority works on this running system from a starting point where no userspace exists yet, no authd is running, no directory has been consulted, and no tokens have been minted by anything but the kernel itself.
The chain of events is short and well-defined. The kernel sets up its own bootstrap tokens directly, attaches one to the first userspace process, and starts it. That first process is prelude, the PID 1 of an in-memory startup environment — the initramfs — whose job is to mount the system's real root filesystem and hand the machine over to it. peinit (signed at TCB, sitting at PID 1 of the real root) then takes over and begins launching services. Eventually authd starts and assumes responsibility for creating real identities for users and services. Each step relies on the previous; the chain has to be intact for the resulting system to be trustworthy.
This page covers the chain at a high level. Later pages cover each part in depth.
What boot establishes #
By the time boot is complete, the system has:
- Two kernel-direct bootstrap tokens (SYSTEM and Anonymous) that exist for the lifetime of the running kernel.
- A peinit process running as PID 1, signed at TCB level, holding the SYSTEM token's privileges plus knowledge of which services to launch.
- An authd process running (started by peinit), populated with the deployment's policy (privileges, claims, CAAP), ready to authenticate users and services.
- A set of service processes running under tokens authd minted, each with their own identity and authority.
- A populated CAAP cache in the kernel (pushed by authd).
- Mount policies applied (set by peinit at boot).
- The handle model in steady state — services have opened the files they need; their fds have cached masks.
Once all of this is in place, the system is "up". Users can sign in (authd handles the authentication; produces tokens; peinit launches their session processes); access checks resolve normally; audit events flow through KMES; everything that the rest of these docs describes is operating.
The interesting question is what happens during boot, when each of those pieces is being established. The chain is a sequence of "this couldn't work before, but now it can":
- Before the kernel finishes init, nothing works. The kernel is itself.
- After kernel init, the kernel has constructed the SYSTEM and Anonymous tokens. The first userspace process — prelude, the initramfs PID 1 — starts running on the SYSTEM token.
- After prelude has run the initramfs, the system's real root filesystem is mounted. prelude switches
/to it and execs the real init. - After peinit takes over at PID 1 of the real root, the kernel has a userspace component that can launch other userspace components. peinit can fork-and-exec services.
- After authd starts, the system has a way to mint real identities for users and services. Until now, every process was running on SYSTEM (inherited from init); from this point on, services can run as their own identities, and users can sign in.
- After authd has populated the CAAP cache, central access policies actually apply. Objects referencing CAAP get evaluated against the policy rather than the recovery policy.
- After mount policies have been applied, FACS knows what to do on each filesystem.
Each transition is the "next thing that has to happen for the system to be coherent". They roughly correspond to the pages in this topic.
The bootstrap problem #
The deeper question: how does any of this work in the first place? authd creates tokens, but authd is itself a process — what token does it run on? peinit launches services, but peinit is also a process — what privilege does it hold to be allowed to mint child tokens? The system has to have authority and identity to do anything, but authority and identity are exactly what boot is supposed to establish.
The kernel solves this with a few "primordial" operations that don't go through the normal pipeline:
- The SYSTEM token is constructed directly by the kernel, not via
kacs_create_token. It's there from the moment the kernel finishes initialising, before any userspace process exists. - The SYSTEM token has every privilege enabled, integrity System, every group SID that any token might ever need. It's authority-maximal.
- init runs on the SYSTEM token. Every child it forks inherits the SYSTEM token until something replaces it.
- peinit is signed at TCB level (verified by the kernel at exec — the verification happens through the same signing path every binary does). Once peinit is running on the SYSTEM token, it has authority to do anything; once peinit is also signed at TCB level, it has PIP authority too.
- authd is similarly signed at TCB and run by peinit with the SYSTEM token. Authd then uses its
SeCreateTokenPrivilege(from the SYSTEM token) to mint other tokens for other processes.
The chain works because each link is held by the previous. The kernel builds the SYSTEM token (link 1); prelude, the initramfs PID 1, runs on it (link 2); peinit takes over from prelude when the system switches to the real root (link 3); peinit launches authd (link 4); authd creates real tokens (link 5). Each link can do its job because the previous link gave it the authority.
The risk: anything that breaks a link breaks the chain. A peinit not signed at TCB level wouldn't get PIP protection at exec; a compromised authd would mint malicious tokens. The signing-and-PIP machinery is what makes the chain trustworthy: each binary in the chain is one whose signature the kernel verifies, and that signature anchors the kernel's trust in what the binary does next.
The "everything is SYSTEM" phase #
There is a brief window during boot where every userspace process is running on the SYSTEM token. From prelude starting in the initramfs, through the early phases of peinit, up until peinit starts launching real services as themselves, every process is SYSTEM.
This is a feature, not a bug. The SYSTEM token is authority-maximal. Whatever early-boot work needs to happen — reading configuration, populating mount policies, launching authd itself — can be done without worrying about whether the running process has the right privileges. It does, because everything is SYSTEM.
The window is short. peinit aims to launch authd as quickly as possible because most of the system is not useful until authd is running. From the moment authd is up, identities can be real, services can run as themselves, users can sign in. The SYSTEM-everywhere phase is what enables the bootstrap, then ends as soon as the real identity machinery can take over.
Services that must not start before authd is ready include anything that handles user requests. A file-server that started in the SYSTEM phase would have its access checks running as SYSTEM, not as the principal it should be acting for — which is operationally wrong even though it's authoritatively allowed. peinit waits for authd to be ready before launching such services.
Services that may start before authd include things that only need their own state — the registry daemon, observability daemons. These can run as SYSTEM for the rest of the boot if they need to, then ideally drop privileges or get reassigned a more specific token once authd is up.
The exact ordering — which services need to wait for what — is part of peinit's startup configuration, not a kernel concern.
Where to start #
If you want the kernel-direct bootstrap tokens — what SYSTEM and Anonymous contain, why they're constructed without going through kacs_create_token, how they relate to the rest of the token model — read Bootstrap tokens.
If you want the initramfs stage — prelude, the in-memory startup environment, the /boot/initramfs/ directory it is built from, and the handoff to the real root — read The initramfs stage.
If you want how the initramfs is composed — boot hooks, the capabilities they declare, how their order is resolved, and how to write one — read Boot hooks.
If you want peinit's role — what makes it the right thing to be PID 1, the service-launching pattern, the lifecycle-manager role that falls out of PIP — read peinit at PID 1.
If you want the authd transition — what authd does at startup, the CAAP cache population, the SYSTEM-everywhere-handoff — read authd handoff.
If you want the kernel-level invariants that the boot chain depends on — the LSM stack, the build config flags, what the kernel refuses to do — read Kernel invariants.
The boot artifacts themselves are built by two command-line tools, the two halves of Peios' Dynamic Boot system. mkirf compiles the /boot/initramfs/ source tree into the deterministic initramfs image; mkuki then wraps that image, together with a kernel and command line, into the single UEFI unified kernel image the firmware boots. Both can run once or stay resident, keeping the boot image current as their inputs change.
Bootstrap tokens
Peios / Peios Security Fundamentals / Boot and trust establishment
The kernel constructs two tokens directly during initialisation, before any userspace process exists. These are the SYSTEM token and the Anonymous token. Both exist as singletons in the kernel from the moment kernel init completes; both persist for the lifetime of the running kernel; both are created without going through kacs_create_token (there is no userspace caller to make that syscall).
The two tokens have opposite roles. SYSTEM is the authority-maximal token — every privilege, every well-known administrative group, integrity System — that gets attached to init and bootstraps every other userspace token. Anonymous is the identity-minimal token — no real principal, no privileges, integrity Untrusted — that backs the Anonymous-level of impersonation.
This page covers what each contains and why they're constructed this way.
SYSTEM — authority-maximal bootstrap #
The SYSTEM token is what init runs on. Specifically:
| Field | Value |
|---|---|
user_sid | S-1-5-18 (the SYSTEM well-known SID) |
groups | BUILTIN\Administrators (with SE_GROUP_OWNER), Everyone, Authenticated Users, Local, and the bootstrap logon SID S-1-5-5-0-0 |
integrity_level | S-1-16-16384 (System integrity — the highest) |
mandatory_policy | NO_WRITE_UP |
privileges | Every privilege defined in the catalog, all present, all enabled |
token_type | Primary |
impersonation_level | Anonymous (conventional for primary tokens) |
auth_id | 0 (the SYSTEM session) |
default_dacl | Grants SYSTEM and BUILTIN\Administrators GENERIC_ALL; denies everyone else by absence |
The SYSTEM token is the most powerful identity on a running Peios system. It has every privilege, full administrative-group membership, the highest integrity. The only thing it lacks is a specific real principal — it's a system-internal identity, not a user.
The SYSTEM token has session ID 0, which corresponds to the SYSTEM session (also kernel-constructed). The session is a peer of the token; both exist together from kernel init.
The default DACL on the SYSTEM token is restrictive in an important way: when SYSTEM creates a new object without specifying an explicit SD, the default DACL ends up on the object. Granting only SYSTEM and Administrators means objects SYSTEM creates are administrator-only by default. This is the right policy for things like service configuration files — they should not be readable by every user just because they were created during boot.
Anonymous — identity-minimal singleton #
The Anonymous token has the opposite shape. It's not used for running processes; it's used as the identity of Anonymous-level impersonation tokens (the lowest impersonation level — see Impersonation levels).
| Field | Value |
|---|---|
user_sid | S-1-5-7 (the Anonymous well-known SID) |
groups | Everyone only |
integrity_level | S-1-16-0 (Untrusted — the lowest) |
mandatory_policy | NO_WRITE_UP |
privileges | None |
token_type | Impersonation (it exists specifically for impersonation) |
impersonation_level | Anonymous |
auth_id | 998 (the Anonymous session) |
The Anonymous token has the bare minimum to be a token at all. No real principal (the user SID is the well-known "Anonymous" placeholder). Just Everyone for groups — explicitly not Authenticated Users. No privileges. Untrusted integrity.
When a client connects to a server and sets the impersonation level to Anonymous (or the gates downgrade the impersonation level to Anonymous), the server's effective token becomes a derivation of this Anonymous token — essentially a copy with the relevant session bindings. The kernel's "Anonymous identity" is centrally defined here; the rest of the system inherits from it.
Why kernel-direct construction #
The two tokens are constructed by direct kernel initialisation, not via kacs_create_token. This matters because kacs_create_token requires SeCreateTokenPrivilege — which requires a caller — which requires a token — which is exactly what's being established. The bootstrap problem.
The kernel breaks the cycle by constructing these two tokens with internal code that doesn't go through the usual API. The kernel can do this because it is the kernel; there's no privilege check on itself.
The implications:
- No record exists of who minted these tokens. The
sourcefield, normally set to whatever component calledkacs_create_token, is set by the kernel to indicate "kernel-internal". The tokens were not minted by authd, peinit, or any other component. - They cannot be re-created at runtime. Even though both tokens have the same shape every time they're constructed (the values are kernel-internal constants), no userspace component can ask the kernel to construct another SYSTEM token at the same level. SeCreateTokenPrivilege lets userspace create new tokens; it doesn't grant access to construct kernel-internal singletons.
- They are singletons. There is exactly one SYSTEM token and one Anonymous token at any moment in the kernel. Other tokens may reference them or be derived from them, but the canonical instances are unique.
The kernel-direct construction is the foundation: it gives the rest of the system a starting identity to build from. Once SYSTEM exists and is attached to init, everything that follows can use normal API calls — they have a token, they have privileges, they can mint other tokens.
What's attached to what #
At the end of kernel init:
- SYSTEM token. Constructed; ready.
- Anonymous token. Constructed; ready.
- SYSTEM session (ID 0). Constructed; the SYSTEM token is bound to it.
- Anonymous session (ID 998). Constructed; the Anonymous token is bound to it.
- init process. About to start; will have the SYSTEM token attached to it.
Init starts and takes the SYSTEM token as its primary token. The first userspace process the kernel runs is prelude, the PID 1 of the initramfs — the in-memory startup environment that mounts the real root (see The initramfs stage). The SYSTEM token survives every exec along the way: prelude runs on it, and when prelude hands the machine to the real root it execs peinit, which takes over with the SYSTEM token as its own.
From this point on, the only entity with the SYSTEM token is peinit (and anything peinit chooses to fork before assigning each child its own token). When peinit launches authd, it does so by forking and then either letting the child inherit SYSTEM (briefly, until authd starts assigning a different token to itself) or by replacing the token with one peinit prepared.
The Anonymous token is not attached to any process. It exists as a kernel-internal object; impersonation tokens at the Anonymous level are derivations of it but the canonical instance lives in the kernel.
Lifecycle #
Both bootstrap tokens are never destroyed during the kernel's lifetime. They are reference-counted like any other tokens, but their references never drop to zero because:
- The SYSTEM token has at least one attachment (initially init, then peinit, then whatever process inherits from peinit). Even after peinit assigns specific tokens to most child processes, peinit itself continues to run on SYSTEM.
- The Anonymous token is a kernel-internal singleton. The kernel holds a reference to it; the reference is not dropped until kernel shutdown.
On reboot, the kernel re-constructs them at init. They are not persistent — there is no on-disk SYSTEM-token record — they are re-built fresh every boot. The fields are deterministic, so the new instances are equivalent to the previous ones structurally; the actual token instances are different objects.
The session IDs (0 for SYSTEM, 998 for Anonymous) are also fixed across boots. The logon SID derived from the SYSTEM session is S-1-5-5-0-0.
Why these specific shapes #
The SYSTEM token has every privilege and full administrative-group membership because it needs to be able to do anything. Boot involves operations that require a wide range of privileges — SeLoadDriverPrivilege to load kernel modules, SeBackupPrivilege / SeRestorePrivilege for early-system manipulation, SeShutdownPrivilege for orderly shutdown. Restricting SYSTEM would force peinit to know in advance which privileges it would need, which is brittle.
SeBackupPrivilege and SeRestorePrivilege are special — they're on the SYSTEM token at boot, but peinit typically calls FilterToken to remove them when assigning tokens to services that don't need them. SYSTEM having them is the starting point; the actual services usually run without them.
The Anonymous token has the opposite design: nothing it shouldn't need. The Anonymous identity is what gets used when a caller has not authenticated, and granting it any group beyond Everyone would risk accidentally giving anonymous callers access to resources whose DACLs name those groups. Empty privileges, Untrusted integrity, no Authenticated Users membership.
The two tokens together establish the trust range. SYSTEM is "the most trust the system can confer"; Anonymous is "the least trust meaningful". Every other token sits somewhere between, with parameters set by authd from the directory.
What the bootstrap tokens are not #
A few clarifications:
- They are not user accounts. SYSTEM is the system's own identity; Anonymous is the placeholder for "no identity". Neither corresponds to a user that signed in.
- They are not interchangeable with administrator tokens. A real user in the
BUILTIN\Administratorsgroup has a different token from SYSTEM — same group membership conceptually, but with the user's actual user_sid, the user's session, etc. SYSTEM and Administrators are not the same identity. - They don't persist across reboots. Every boot constructs fresh instances. Anything keyed to the SYSTEM token's instance ID (which is rare; nothing should be) would lose its reference at reboot.
- They cannot be modified. Almost everything about them is immutable. AdjustPrivileges would technically be possible on SYSTEM (a token holder of SYSTEM could enable/disable privileges, or even remove them) but the kernel makes such adjustments a one-time event for the bootstrap token, and the field-level operations are constrained. In practice nothing modifies the SYSTEM token directly; peinit makes filtered copies for child tokens.
Where to go next #
For the first userspace process the SYSTEM token is handed to, read The initramfs stage.
For the moment real identities start replacing SYSTEM, read authd handoff.
For what a token is in general — types, fields, lifecycle — read Tokens.
The initramfs stage
Peios / Peios Security Fundamentals / Boot and trust establishment
The kernel cannot reach the real root filesystem on its own. By the time it has finished its own initialisation it can speak to a CPU and some memory, but the disk the system is installed on may sit behind a storage driver that is not yet loaded, a volume manager that has not been assembled, or an encrypted container that has not been unlocked. Mounting that filesystem is work, and it is work that has to be done in userspace — the kernel does not mount real roots by guesswork.
So there is a stage in between. The bootloader loads a second thing into memory alongside the kernel: a small, complete root filesystem called the initramfs. The kernel unpacks it into a RAM-backed filesystem, makes that filesystem /, and starts a PID 1 inside it. This in-memory system exists for one purpose — to get the real root mounted and then get out of the way. On Peios, the PID 1 of that in-memory system is prelude.
The initramfs stage sits between two of the other pages in this topic. Bootstrap tokens covers what the kernel constructs before any userspace exists; peinit at PID 1 covers the init system that runs on the real root. prelude is what runs in between — the first userspace process the system ever has, and the last thing that runs before the real system begins.
prelude in the trust chain #
prelude is the first userspace process on the machine. The kernel attaches the SYSTEM token to it — prelude is the "init" that Bootstrap tokens describes the SYSTEM token being handed to. From the moment prelude starts, the "everything is SYSTEM" phase of boot (covered in the overview) is underway: inside the initramfs there is exactly one identity, SYSTEM, and exactly one job, getting to the real root. There is no directory, no authd, no notion of separate principals — and no need for one. The initramfs is a single-purpose, single-identity environment.
The initramfs's integrity is the integrity of the initramfs image itself. The image is a sealed archive: once it is built, nothing edits it in place. It is read into RAM at boot, used once, and discarded at the handoff — there is nothing inside it to tamper with at runtime. Under Secure Boot (a later milestone), the firmware verifies the whole boot artifact — kernel and initramfs together — before the kernel is allowed to run at all, so the sealed image is also a verified one.
prelude — the initramfs PID 1 #
prelude is a small, dedicated init. It is not a general-purpose process manager and it is not peinit: peinit supervises services for the system's entire lifetime, whereas prelude runs one short, fixed sequence and then replaces itself with the real init. They are separate programs with separate jobs. The name reflects the relationship — prelude is the short piece that runs before peinit's main one.
What prelude itself does is deliberately minimal. It is the invariant skeleton of the boot — the part that is identical on every Peios machine. Preparing the kernel's virtual filesystems, running the hooks, checking that a root was mounted, switching to it: that is the whole of prelude. Everything that varies between machines — which storage driver is needed, whether there is disk encryption, what kind of filesystem the root is — is not in prelude at all. It is in boot hooks.
This division is the important design point. A machine that boots from a plain disk, a machine that boots from an encrypted volume, and a machine that boots over a network differ only in their hooks. prelude is the same binary on all three. It never has to change to support a new kind of deployment — a new deployment is a new hook, not a new prelude.
Where the initramfs comes from #
The initramfs is not a mysterious binary blob. It is compiled from an ordinary directory on the real root filesystem: /boot/initramfs/. Whatever is in that directory becomes the contents of the in-memory root. The directory is the source; the initramfs image is the build product.
The layout is straightforward:
/boot/initramfs/
init symlink to the prelude binary — the initramfs PID 1
usr/
sbin/prelude the prelude binary itself
bin/
dash the shell; sh -> dash provides /usr/bin/sh
peiosutils one multi-call binary backing mount, chroot, ls, … via symlinks
lib64 -> usr/lib/x86_64-linux-peios
the x86-64 ABI loader path; /bin, /sbin and /lib views
do not exist before the runtime topology is mounted
usr/libexec/prelude/hooks.d/
mount-root.sh mounts a live medium's squashfs (live-boot)
mount-root-disk.sh
mounts an installed root partition (disk-boot); both
contribute rootfs-ready, and `root=` on the cmdline
decides which one acts
mount-rootfs-stratafs-base.sh
mounts the real root's StrataFS views after rootfs-ready
...
lcl/libexec/prelude/hooks.d/
operator-placed hooks; a file here replaces a packaged
hook of the same name
It is a normal directory. You can list it, read it, and see exactly what the initramfs will contain — inspecting the boot environment is ls /boot/initramfs/, not unpacking an archive. This is intentional: the initramfs should feel like part of the filesystem an administrator already understands, not a separate, opaque build system.
Most of what lands in the directory is put there by packages. A boot feature — disk encryption, an exotic storage backend — is an ordinary peipkg; installing it drops its hook (and any helper binaries) into /boot/initramfs/, and removing the package takes them away. There is no separate "initramfs configuration" to edit and no central list of supported features: the directory is the configuration, and a package contributes to the boot simply by installing files into it. Boot hooks covers this in full.
Because hooks are shell scripts, the initramfs has to carry a shell. That shell is dash: it provides the sh capability prelude depends on and supplies /usr/bin/sh. Hooks use #!/usr/bin/sh, because they run before any root-level /bin StrataFS view exists. The ordinary utilities a hook invokes — mount, chroot, ls, and the rest — come from peiosutils, a single multi-call binary that backs each tool through a symlink (mount, chroot, and friends are all the one binary, invoked under different names). Driver- or filesystem-specific tools a particular hook needs — blkid, a mkfs helper — arrive with the feature package that ships that hook, not with the base initramfs. Module loading is the exception: modprobe comes from kmod and the modules themselves from kernel-modules-irf, both base packages rather than per-feature ones, because any hook that touches storage may need them.
Kernel modules in the initramfs #
The initramfs carries its own set of kernel modules, shipped by the kernel-modules-irf package. It is a separate root from the real one and shares nothing with it, so it needs its own copy of anything it uses — the same reason it needs its own mountpoints and its own loader link.
The set is deliberately a subset. The initramfs only has to reach the root filesystem, so it carries storage controllers and block devices, the input drivers a passphrase prompt needs, filesystem and crypto drivers, and network drivers for a root reached over the network. It does not carry graphics, sound, media or wireless: the real root can load those for itself once it is mounted, and everything in the initramfs is paid for on every boot — it is loaded whole into memory, and under a UKI it sits inside an image the firmware verifies in one piece.
The set is also generic: every machine gets the same modules, rather than a set tailored to the hardware it was built on. Two things make that the only workable choice. Composing a root resolves offline and runs no install-time side effects, so nothing in that path may inspect the hardware it is composing for. And because a UKI is signed as a single image, a per-machine initramfs would have to be assembled and signed on the target — which would mean a signing key on every installed machine. A generic initramfs is also what mainstream distributions default to; tailoring it is the opt-in, not the norm.
A module being available in the initramfs is not the same as it being loaded, and the kernel never loads a driver for hardware on its own: it enumerates the devices and emits a uevent for each one naming the module that can drive it, but binding a driver to a device is userspace's job. In the initramfs that job is done once, by the coldplug hook that the coldplug-irf package ships. It runs after the initramfs's own views are assembled and before any root-mount hook, reads the modalias of every device the kernel has enumerated under /sys/bus, and hands the whole set to modprobe in one call. A machine whose root sits behind a modular storage driver — NVMe, for instance — has that driver loaded by the time the root-mount hook goes looking for the disk. Aliases that match no module are the normal case and are ignored; a module that fails to load is reported and is not, on its own, a boot failure — whether the missing driver matters is for the root-mount hook to decide, because it is the one that knows which disk it needs.
The hook is a single pass and does not stay resident, which is enough for the initramfs: everything that matters to reaching the root is present before PID 1 runs. Devices that appear later, and the stable /dev/disk/by-* names, belong to the device manager on the real root.
What prelude does at boot #
When the kernel starts prelude, it runs one fixed sequence, in order:
- Prepare the environment. prelude mounts the kernel's virtual filesystems —
/proc,/sys,/dev— so that it and the hooks can see processes, devices, and kernel state, and arranges the mount environment so the later root switch is unobstructed. - Determine the target init. prelude reads the kernel command line for an
init=value — the program to hand off to on the real root. The shipped image names/bin/peinit2. If the command line does not name one, prelude searches/bin/peinit2,/sbin/init,/bin/init, and finally/bin/sh. These are target-root runtime paths: the base-topology hook creates their StrataFS views before prelude hands off. Prelude's own initramfs rescue shell remains/usr/bin/sh, because that environment has no/binview. - Run the hooks. prelude creates an empty
/mnt/rootfsdirectory — the mount point the real root will appear at — and runs the boot hooks in order. The hooks do the deployment-specific work: load drivers, unlock encryption, assemble volumes, and mount the real root onto/mnt/rootfs. The packagedmount-rootfs-stratafs-base.shhook (from thefsbasepackage) then mounts the conventional runtime views inside that root. prelude does none of this itself; it runs the hook list. The order was decided when the initramfs was built (see below, and Boot hooks). - Verify the real root. After the hooks have run, prelude checks that
/mnt/rootfsactually has a filesystem mounted on it. If no hook mounted a root, prelude fails the boot rather than handing off to nothing. This is the one outcome prelude insists on: some hook must have produced a mounted root. - Hand off. prelude carries the kernel virtual filesystems into the new root, frees the now-finished in-memory root to reclaim its space, switches
/to the real root, and executes the target init. From that exec onward, the initramfs is gone and the real system is running.
prelude runs this sequence exactly once. It does not loop, supervise, or stay resident — the moment the real init is exec'd, prelude has ceased to exist; the exec replaces it. Its entire lifetime is the few seconds of the initramfs stage.
When the initramfs stage fails #
prelude does not limp. If any step fails — a hook exits with an error, no hook mounts the root, the target init cannot be found — prelude stops and halts the machine. It does not fall through to a half-configured system, and it does not try to work around a failed hook.
This is the right behaviour for the stage. The initramfs's only job is to deliver a correctly-mounted real root to a real init. A failure there means the system cannot be brought up safely; continuing would only produce a system in an undefined state. Halting makes the failure unambiguous — the console shows which step failed — and leaves the operator with a clean situation to diagnose rather than a subtly-broken running system. The one way to intervene rather than halt is the debug knobs below.
Debugging the initramfs stage #
Two kernel command-line knobs (familiar from dracut) turn the fixed sequence into something an operator can step through. prelude reads them from /proc/cmdline, the same place it reads init= — as PID 1 it has no argv, so the command line is its only input. The interactive shell they open is dash, run as sh -i on the console.
-
rd.shell— drop to a shell on failure instead of halting. If any boot step fails whilerd.shellis set, prelude opens the shell so you can inspect the half-built initramfs: see what the hooks mounted, read the log, try a mount by hand. The failure still ends the boot — when you exit the shell, prelude halts as it would have anyway. Withoutrd.shell, a failure halts immediately. -
rd.break— stop before any hook runs. Barerd.breakpauses just before the first hook and opens the shell; exit the shell and the boot continues normally. This is the point where the kernel virtual filesystems are up but nothing deployment-specific has happened yet. -
rd.break=<hook>— stop just before a named hook. The value is matched against a hook's file name — or its full path — as it appears in the resolved boot sequence, sord.break=mount-root.shbreaks immediately before that hook and lets everything ordered before it run first. The value is comma-separated and the flag may be repeated —rd.break=modules.sh,mount-root.sh— to break before several hooks.
The rd.break shells are true breakpoints, not failures: prelude forks them, so it stays PID 1 and the boot resumes exactly where it paused when the shell exits. Setting any rd.break also implies rd.shell — so if the boot goes on to fail, you get the failure shell too.
Keeping the initramfs current #
Because the directory is the source and the initramfs image is a build product, the two have to be kept in step. Whenever /boot/initramfs/ changes — a feature package is installed or removed, the kernel is updated, an administrator edits a hook — the image has to be rebuilt from the directory.
The tool that does this is mkirf — see mkirf for the full command reference. It reads /boot/initramfs/, resolves the order the hooks must run in, checks the directory is internally consistent, and writes the compressed initramfs image. Three properties of it matter to an operator:
- It validates. mkirf will not produce an image from a directory that cannot boot. A hook set with an impossible ordering, a hook that depends on a capability nothing provides, a missing
init— each of these stops the build with a clear error. A misconfigured boot is caught when the image is built, on a running system where the message is easy to read, rather than as a mystery failure at the next boot. Boot hooks covers exactly what is checked. - It is deterministic. The same directory always compiles to the same image, byte for byte — identities, timestamps, and ordering are all normalised. This is what makes "did anything actually change?" a meaningful question, and it underpins later work such as signed boot artifacts.
- It keeps the directory pristine. mkirf reads the directory and writes the image elsewhere; it never writes back into
/boot/initramfs/. The resolved hook order is recorded inside the image, not in the source. The directory you inspect is always exactly what packages and you have put there.
mkirf can run once and exit, or run in a watch mode where it stays resident and recompiles automatically whenever the directory changes. Watch mode is what lets /boot/initramfs/ behave like an ordinary part of the filesystem — edit a hook and the initramfs is current again — rather than a build artifact an administrator has to remember to regenerate.
When more than one kernel is installed, there is one initramfs per kernel: the drivers a kernel needs are specific to its version, so each kernel gets an image built against its own modules.
The handoff to the real root #
The final act of the initramfs stage is the handoff: prelude replaces the in-memory root with the real root and gives the machine to the real init.
Concretely, the real root has been mounted at /mnt/rootfs by a hook; prelude carries the kernel virtual filesystems across into it, switches / from the initramfs to /mnt/rootfs, and execs the target init. Switching the root is the operation usually called switch_root — after it, / is the real filesystem and the in-memory initramfs is gone, its RAM reclaimed.
The init prelude execs is the one identified back in step 2 — the init= value from the kernel command line, or the fallback search. On a complete Peios system that init is peinit, which takes over as PID 1 of the real root and brings up the rest of userspace. During earlier development, before peinit exists, the target is a simpler stand-in; the contract is the same either way — prelude delivers a mounted real root and a console, and execs whatever the real init is.
The handoff is a one-way door. Once / is the real root and the real init is running, the initramfs is not coming back; the in-memory stage exists only up to this moment. The SYSTEM token, though, survives the handoff — the real init inherits it across the exec, exactly as every program inherits its primary token across exec. That is the thread tying this page to Bootstrap tokens: the SYSTEM token the kernel attached to prelude is the same token peinit starts life holding.
What prelude does not do #
A few clarifications:
- prelude does not mount the real root itself. A hook does. prelude provides the
/mnt/rootfsmount point and verifies the result, but the mount is always a hook's job — that is what keeps prelude identical across deployments. See Boot hooks. - prelude does not decide hook order. The order is resolved when the initramfs is built, by mkirf, and recorded in the image. prelude reads the resolved list and runs it; it has no ordering logic of its own.
- prelude does not supervise anything. It runs the hook sequence once and execs the real init. It has no steady state — contrast peinit, which runs as the system's lifecycle manager for the whole of its uptime.
- prelude is not the real init. It is the initramfs init. peinit is the real-root init. They are separate binaries with separate jobs; prelude's last act is to exec the real init, not to become it.
- prelude does not persist anything. Nothing it does is written to disk. The initramfs is in RAM, used once, and discarded. Persistent state begins on the real root, with peinit.
Where to go next #
For the deployment-specific scripts prelude runs, read Boot hooks.
For the init system prelude hands the machine to, read peinit at PID 1.
For the tool that compiles /boot/initramfs/ into the image, read mkirf.
Boot hooks
Peios / Peios Security Fundamentals / Boot and trust establishment
A boot hook is a shell script that runs inside the initramfs, before the real root is mounted — during the initramfs stage. Hooks are how Peios keeps prelude deployment-agnostic: every part of early boot that depends on this particular machine is a hook, and prelude runs the hooks without knowing what any of them do.
The work hooks do is the work of getting to the real root: loading the storage driver that makes the system's disk visible; unlocking an encrypted container; assembling an LVM volume group or a RAID array; and, finally, mounting the real root filesystem onto /mnt/rootfs. None of that is the same on two arbitrary machines, so none of it is built into prelude — it is all hooks. prelude supplies the fixed skeleton of the boot; hooks supply everything that varies.
Where hooks live #
Hooks live in two directories, which mirror the layout's vendor/operator split everywhere else:
| Directory | For |
|---|---|
/usr/libexec/prelude/hooks.d/ | hooks shipped by packages |
/lcl/libexec/prelude/hooks.d/ | hooks an operator put there by hand |
Every regular file directly in either directory is a hook, and prelude runs all of them. Neither is searched recursively — a subdirectory is not a hook — and the file extension does not matter. A hook is recognised by being a file in one of those directories, and it is run according to its #! shebang line, the same way any script is run.
A file name identifies a hook. The same name in both directories is one hook with two candidate bodies, not two hooks: the operator's copy wins and the packaged one is skipped, so a shipped hook can be replaced without deleting a package's payload. The build says which file lost, because a hook silently replaced by another is exactly the kind of thing that should not be quiet.
An older hooks/ directory at the initramfs root is still read, and every hook found there produces a build warning naming where it should move. That is deliberate: a hook in a directory nobody scans is not an error — it simply is not there, and the boot fails much later with nothing mounted — so the move cannot be a flag day. The warning going quiet is what says the migration is done. Hooks are #!/usr/bin/sh scripts, and the initramfs's /usr/bin/sh is provided by dash. The full package-storage path is required because hooks run before a /bin runtime view exists.
How hooks get there #
There are two ways a hook reaches the directory, and they are identical as far as prelude is concerned:
- From a package. Most hooks arrive as part of a feature peipkg. Installing
peios-luks(disk encryption) drops a hook that unlocks encrypted volumes; installing a filesystem feature drops a hook that mounts that kind of root. The package's payload simply includes a file under/usr/libexec/prelude/hooks.d/, and removing the package removes the hook. This is the feature-as-a-package model applied to boot: there is no edition of Peios that "has LUKS" and another that does not — there is apeios-lukspackage, and a machine either has it installed or it does not. - By hand. An administrator can write a hook and place it in
/lcl/libexec/prelude/hooks.d/directly. A site with an unusual storage arrangement, or a one-off need, does not have to build a package — a script in the directory is a hook.
Either way, the next time the initramfs is built the new hook is picked up. (See The initramfs stage for the build.)
The ordering problem #
Hooks have an order, and the order matters. A hook that mounts the root cannot run before the hook that loads the disk's storage driver; a hook that unlocks an encrypted volume cannot run before that volume's driver is loaded. Run them in the wrong order and the boot fails.
The obvious approach — number the files, 10-modules, 20-crypto, 30-mount, and run them in numeric order — is the approach Peios deliberately does not take. Numeric prefixes work right up until two packages, written by people who never spoke to each other, both pick 20. Then every package author has to know the whole number line, and the "order" is a fiction held together by convention. This is the historical sysvinit problem, and it does not scale.
Instead, a hook declares what it needs and what it offers, in terms of named capabilities, and the order is computed from those declarations. The hook that mounts the root says "I require crypto-unlocked"; the hook that unlocks encryption says "I provide crypto-unlocked"; the order follows. No hook needs to know any other hook's name or position — only the capabilities. Packages that never met each other compose correctly because they agree on capability names, not on numbers.
The metadata block #
A hook declares its capabilities in a metadata block at the top of the script: a fenced comment block, every line of it a comment, so the block is invisible to the shell when the script actually runs.
#!/usr/bin/sh
# /// hook
# contributes = ["rootfs-ready"]
# ///
# ... the hook's actual work follows ...
The rules of the block are small and exact:
- It opens with a line that is exactly
# /// hookand closes with a line that is exactly# ///. - Every line in between is a comment line —
#followed by content, or a bare#. This is what keeps the block inert: the shell sees only comments. - The content of those lines, with the
#stripped, is up to four keys:provides— capabilities this hook satisfies on its own. Several hooks may provide the same capability, and any one of them suffices; they are alternatives.contributes— capabilities this hook is one part of. Every contributor must complete before the capability counts as satisfied.requires— capabilities that must already be satisfied before this hook runs. If nothing supplies one, the build fails.after— capabilities to be ordered after if anything supplies them. If nothing does, the hook simply runs; this is not an error.
- Each key's value is a list of capability names in double quotes —
provides = ["a", "b"]. An empty list is allowed, and a trailing comma is allowed.
Why there are four keys and not two #
The four are two ways to supply a capability crossed with two ways to consume one, and each pair exists to say something the other cannot.
contributes is how a hook runs before something. A hook is otherwise ordered only by what it consumes — so "run before the root is mounted" would require the root-mounting hooks to name your hook, which they cannot, because they were packaged before it existed. Declaring yourself part of a capability inverts the relationship: everything that consumes it now waits for you. Without this, the only way to be early is to win the file-name tie-break, which is the numeric-prefix problem wearing a disguise.
after is how a shared vocabulary survives a small image. A requires on a capability nothing supplies is a build error — deliberately, since it catches an initramfs that cannot possibly boot. But that makes any standard capability name dangerous: a hook mentioning network-up would break every image that has no networking. after expresses "if this happens at all, it happens before me", which is what most ordering against an optional milestone actually means.
provides and contributes differ in what "satisfied" means. With alternatives, one supplier doing the job is enough — which is exactly the live-boot/disk-boot pair below, where one mounts the root and the other stands aside. With contributors, the capability is not satisfied until every one of them has completed — three hooks each unlocking a layer of an encrypted stack, say. A capability must be one or the other; declaring it both ways is a build error, because there would be no answer to whether it is satisfied yet.
The format is a small, deliberate subset of TOML: enough to declare two lists of names, and no more. It is checked strictly when the initramfs is built — an unknown key, a list that is not well-formed, a block that is opened and never closed, two blocks in one file — each of these is a build error, not a quiet misread. A typo in a hook's metadata is caught at build time, with a message naming the hook and the line.
The metadata lives inside the hook script, not in a separate file, so a hook is a single self-contained thing: copy the script and its ordering travels with it.
The capability vocabulary #
Capabilities are just names, and a hook can in principle name its own. But the milestones every initramfs passes through are a small fixed vocabulary, so that hooks from unrelated packages line up against the same reference points:
| Capability | Meaning |
|---|---|
initramfs-ready | The initramfs itself is usable — its filesystem topology assembled, its console set up. |
rootfs-ready | /mnt/rootfs is mounted and ready for read/write manipulation. |
rootfs-strata-ready | The full StrataFS topology exists inside /mnt/rootfs. |
The vocabulary is deliberately short. Names for things Peios does not yet do — device settling, volume assembly — are not reserved in advance: a capability that nothing supplies and nothing consumes is a name with no meaning behind it, and inventing one early only fixes a shape before we know it. Driver loading, which Peios does do, has no capability of its own for the same reason: the coldplug hook simply contributes to rootfs-ready, which is what it means for the root-mount hooks, and nothing has yet needed to order against it more precisely than that.
Once every hook has finished, prelude chroots into /mnt/rootfs. There is no "last point at which a hook can run" capability, because that is already every hook's guarantee.
initramfs-ready is the one capability with a rule #
A hook that must run before all the others cannot say so by ordinary means — it would need every other hook to name it, including hooks written by people who have never heard of it, whose forgetting would not fail the build but would quietly run their hook in an initramfs with no /bin.
So initramfs-ready carries one extra rule:
Every hook that does not supply
initramfs-readyis implicitly ordered after it.
The exemption is derived, not declared — you are exempt exactly when you are part of the capability. There is no cycle to construct and nothing for a hook author to remember. A hook assembling the initramfs's own topology simply declares contributes = ["initramfs-ready"] and lands before everything.
Two things fall out of the existing rules rather than needing special cases. An initramfs with no contributors leaves the capability unsupplied, and an after on an unsupplied capability is vacuous — so a minimal initramfs just runs, with nothing to configure. And a hook carrying no metadata block does not gain the edge, because it already runs after every declaring hook; adding it would have pulled the escape hatch into the DAG.
mkirf materialises these edges into the sequence as ordinary after entries, rather than leaving prelude to know the rule. That keeps one implementation instead of two that have to agree, and makes the sequence file explain itself — in a rescue shell you can read why a hook ran where it did, instead of needing a rule that appears nowhere in the image.
More than one hook may supply the same capability #
Nothing limits a capability to a single supplier, and rootfs-ready is where that matters: getting a root filesystem ready is several jobs. A hook that unlocks an encrypted container, a hook that assembles a volume group, and a hook that performs the mount each contributes = ["rootfs-ready"], and the capability is not achieved until all of them have finished. None of them needs to know the others exist.
A capability does not have to be a hard, machine-specific thing. What matters for ordering is the declaration, not how much work the hook does to honour it. An encryption hook on a machine with no encrypted volumes has nothing to unlock and declines (exit 69) — which, for a capability it contributes to, completes its part: "nothing needed doing here" is a way of being done. The capability is still achieved, and everything waiting on it proceeds.
How the order is resolved #
When the initramfs is built, mkirf reads every hook's metadata and computes the running order:
- A hook that consumes a capability (by
requiresor byafter) runs after every hook that supplies it (byprovidesor bycontributes). That single rule is the whole of the ordering. - Hooks with no ordering relationship between them run in a stable, predictable order — by file name — so the resolved sequence is the same every time the initramfs is built.
Note that ordering does not distinguish alternatives from contributors, nor hard requirements from soft ones: all four keys produce the same "supplier first" edge. What they change is validity — whether an unsupplied capability is an error — and what a capability being "satisfied" will mean to prelude at boot.
Three situations are build errors. They stop the build, and no initramfs is produced:
- A cycle — hook A consumes something B supplies, and B consumes something A supplies. There is no order that satisfies both; the build reports the hooks caught in the cycle.
- An unsatisfied requirement — a hook
requiresa capability that nothing installed supplies. Rather than build an initramfs that is guaranteed to fail at boot, mkirf reports the missing capability. Anafteron an unsupplied capability is explicitly fine. - A capability that is both provided and contributed to — one hook calls it an alternative, another calls itself a part of it. The two answers to "is it satisfied?" contradict each other, so mkirf names both sides and stops rather than picking one.
This is the payoff of declared capabilities: an impossible or incomplete hook set is a build error on a running system, with a readable message, never a boot that hangs with no explanation.
The resolved order is recorded inside the initramfs image, at /system/prelude/hooks.seq.<n>, and prelude reads it at boot. An operator does not write or edit those files — they are generated. The hook directories are the source of truth; the order is derived from what they contain.
The <n> is the format version, and mkirf writes every version it can express faithfully, prelude reading the newest it understands. That is not ceremony: mkirf ships in peiosutils and prelude in its own package, so an image can pair a newer writer with an older reader, and separate files are what let one image satisfy both. A sequence newer than prelude understands is a warning when a usable one sits beside it, and a boot failure when it is the only one present.
A missing sequence is always an error, never "no hooks to run" — mkirf writes one for every image, including an empty one for an image with no hooks at all. Were an absent file read as "nothing to do", a manifest that failed to generate would become a boot that silently ran no hooks, mounted no root, and reported the failure a long way from its cause.
Hooks with no metadata #
A hook is allowed to carry no metadata block at all. It is still a valid hook — it simply has no declared capabilities, and therefore no ordering constraints. Such a hook runs after all the hooks that do have constraints, in file-name order.
The build emits a warning for a hook with no block — because a hook that merely forgot its metadata looks identical to one that genuinely has none, and the warning makes the difference visible. A hook that is deliberately unconstrained can carry an empty block — a # /// hook line immediately followed by # /// — to say so on purpose, which suppresses the warning.
How prelude runs a hook #
At boot, prelude runs the hooks one at a time, each to completion before the next begins, scheduling from their declarations — repeated passes over the sequence, running whatever is ready, retrying anything that deferred once something else has progressed. Each hook runs as a separate process with a minimal environment — PATH=/usr/bin and TERM=linux — so the initramfs's shell and utilities (dash, peiosutils, and any tools a feature package added) are found without depending on a not-yet-mounted root-level view.
A hook reports what happened through its exit code, and there are four things it can say:
| Exit | Meaning | What prelude does |
|---|---|---|
0 | Satisfied — I did my part. | Its provides are achieved; its contributes are one step closer to complete. |
69 | Declined — not applicable on this machine. | It is not counted toward the capabilities it declared. |
75 | Deferred — I cannot run yet, and I have changed nothing. | Re-queued and tried again once something else has made progress. |
| anything else | Failed. | The boot stops and the machine halts. |
The two middle codes are borrowed from sysexits.h (EX_UNAVAILABLE, EX_TEMPFAIL) rather than invented, for a practical reason: 1 and 2 are what any failing command returns and 126, 127 and 128+n are the shell's own, so a small dedicated range is the only place a deliberate signal cannot be mistaken for an accident. A hook killed by a signal is always a failure, whatever code it might have produced.
Declining is not the same as succeeding #
A hook that is not the right one for this machine declines (69) rather than exiting 0. The difference matters because provides means alternatives: a capability with several providers is achieved as soon as one of them is satisfied, and a provider that exits 0 is claiming to be that one.
This is what the live-boot/disk-boot pair does. Whichever hook root= does not select declines, and the other mounts the root. If every provider declines, the capability is never achieved and prelude stops with a message naming it — where previously a machine no hook would boot produced only the generic "nothing mounted the root" much later.
For contributes the sense is reversed: since every contributor must complete, a contributor that declines has completed — "nothing needed doing here" is a way of being done.
Deferring is a promise that nothing happened #
Deferred (75) means the hook's inputs are not ready yet. prelude runs hooks in repeated passes, retrying deferred ones once something else has progressed, and stops with a diagnosis if a whole pass completes nothing while work remains.
The contract that makes this safe is that a deferring hook has done nothing. Re-running it is free in a way that re-running a failed hook could never be — a failure may have left a half-assembled array, an opened device, or a burnt unlock attempt behind it. So:
- Defer before you act, never after. Check whether you can do the job; if not, exit
75immediately. Do not defer partway through. - Failure is still failure. If the work was attempted and went wrong, exit non-zero. Deferring a genuine error turns one clear failure into a stall reported as "nothing left to wait for".
This is what lets layered arrangements compose without absolute ordering: three hooks each contributing to an encrypted stack can be written without knowing which layer comes first, because the ones whose devices do not exist yet simply defer and are retried.
A few more consequences for anyone writing a hook:
- Check, and exit non-zero on failure. A hook that mounts the root must exit non-zero if the mount failed. A hook that fails silently turns into prelude's generic "nothing mounted the root" failure later — which is harder to diagnose than the hook reporting its own error at the point it happened.
- Hooks run as SYSTEM. Everything in the initramfs runs on the SYSTEM token (see Bootstrap tokens), so a hook has full authority. There is no identity model to work within inside the initramfs — that begins on the real root.
Writing a hook #
A complete, minimal hook — one that mounts an ext4 root from a known partition:
#!/usr/bin/sh
# /// hook
# contributes = ["rootfs-ready"]
# ///
# The device may not exist yet — another hook may still have to unlock or
# assemble it. Defer rather than fail: exit 75 promises we changed nothing,
# so prelude can retry us once something else has made progress.
[ ||
||
What this declares: it is one part of rootfs-ready, so anything waiting on a usable root filesystem is ordered after it. It does not name the hooks it depends on, because it does not know them.
A hook that unlocks the container it mounts, to pair with it:
#!/usr/bin/sh
# /// hook
# contributes = ["rootfs-ready"]
# ///
# Nothing encrypted on this machine — decline, which completes our part of
# the conjunction without claiming to have unlocked anything.
[ ||
||
Installed together, these two compose without either one naming the other, and without an ordering declaration between them at all. If the mount hook runs first it finds no device and defers; the unlock hook then runs; the mount hook is retried and succeeds. Add a volume-assembly hook later — also just contributes = ["rootfs-ready"] — and it slots in the same way, because each hook knows only whether it can act yet.
That is the model: hooks are composed by capability, ordering is declared only where it is genuinely known, and the rest is settled at boot by hooks that can say "not yet".
What hooks are not #
A few clarifications:
- Hooks do not run on the real root. They run inside the initramfs, before the handoff. Work that belongs to the running system — starting services, applying mount and access policy — is peinit's job, not a hook's.
- A boot hook is not a service. "Hook" here means specifically an initramfs hook. The initramfs stage ends when prelude execs the real init; everything after that — services, supervision, restart policy — is peinit's domain and works nothing like a hook.
- The generated order file is not edited by hand. It is the build's record of the resolved order. The
hooks/directory is the source; the order is computed from it, every time the image is built. - File names are not the ordering mechanism. A file name identifies a hook, and is the tie-break between hooks that have no capability relationship. Renaming a hook does not change where it runs relative to a hook it shares a capability with — the
provides/requiresdeclarations do that.
Where to go next #
For the stage that runs the hooks, read The initramfs stage.
For the build that validates hooks and resolves their order, read mkirf.
For what takes over once a hook has mounted the real root, read peinit at PID 1.
peinit at PID 1
Peios / Peios Security Fundamentals / Boot and trust establishment
peinit is the init system of a running Peios system — the process that takes over once the initramfs stage has mounted the real root. It runs at PID 1, signed at TCB level, holding the SYSTEM token. From there, peinit does the rest of the work needed to bring the system from a freshly-mounted real root to "fully-running with services and users".
The specific responsibilities are:
- Run the compiled-in Phase 1 bootstrap: probe that the root is writable, mount the virtual filesystems, restore the random seed, ensure the machine-id, set the clock from the RTC, and start registryd.
- Read the service catalog from the registry (
Machine\System\Services\), validate the dependency graph, and start services in dependency order — authd among them. - Mint SYSTEM tokens for platform services itself; request every other identity from authd.
- Continue running as the system's lifecycle manager — handling service crashes, system shutdown, eventual reboot.
This page covers each of these responsibilities, why peinit specifically is the right thing to be PID 1, and the patterns peinit uses for the work.
Why peinit at PID 1 #
PID 1 has special properties in Linux: it cannot be killed by ordinary signals, it inherits orphaned processes, it's the ancestor of everything else. Whichever process is PID 1 is operationally critical.
Peios chooses peinit specifically because:
- It's signed at TCB level. When peinit is exec'd — by prelude, at the switch to the real root — the signature verification at exec sets
pip_type = Protected,pip_trust = 8192. peinit is now PIP-protected at the highest level. No other process can signal it, debug it, or interfere with it. - It runs on the SYSTEM token. Inherited from init. peinit has every privilege; it can do anything that requires authority.
- It's purpose-built for the bootstrap. Other init-style processes do general-purpose process management; peinit specifically knows about Peios's identity model, its registry-defined service catalog, and the service-launching pattern Peios uses.
The combination of "PIP-protected" and "all privileges" and "purpose-built" makes peinit the right thing to be PID 1. A general-purpose init that wasn't signed at TCB level would be a weak link — every TCB-level process (authd, loregd, eventd) is unreachable by their lifecycle manager, which means lifecycle management could only be done by another TCB-level process. That role is peinit.
The TCB lifecycle-manager pattern #
A consequence of PIP: only a process that PIP-dominates the TCB daemons can signal them. Since the TCB daemons are at pip_type = Protected, pip_trust = 8192, only another process at the same level (or higher, but there is no higher in v0.20) can dominate.
peinit is the only such process at the right time. peinit is signed at TCB level, so it dominates. authd, loregd, eventd, lpsd are also at TCB level, so they dominate each other — but they don't manage each other's lifecycles by convention. peinit is the conventional lifecycle manager.
This is documented in PIP in practice. The TCB-lifecycle-management pattern is a consequence of PIP; peinit happens to be the binary that fills the role.
What peinit does at startup #
peinit's startup is two phases. The authoritative, step-by-step account lives in the peinit topic at Boot and boot modes; what follows is the trust-chain summary.
Phase 1 — compiled-in bootstrap #
Phase 1 runs before any configuration is available, in a fixed order compiled into the binary:
- Assert PID 1. peinit refuses to run as anything else.
- Probe that the real root is writable, and ensure the virtual filesystems (
/proc,/sys,/dev, …) are mounted. - Restore the random seed, ensure the machine-id, set the clock from the RTC.
- Start registryd and probe the registry schema. The registry is the source of all subsequent configuration, so its daemon comes up before anything else.
- Provision boot paths and infrastructure — the control socket, the job filesystem, loopback networking.
Phase 2 — the service graph #
With the registry available, peinit reads the service catalog from Machine\System\Services\, validates the dependency graph, and starts services in dependency order. authd is an ordinary Critical service in this graph — started after the infrastructure it depends on (eudev, lpsd), gated by the same readiness mechanics as any other service, not by a special wait-for-authd phase.
For each service, the pattern is fork-install-exec:
- peinit obtains the service's token — minted by peinit itself for SYSTEM/platform services, requested from authd for every other identity (see Identity and privileges).
- peinit forks; the child inherits a SYSTEM-derived token.
- peinit installs the service-specific token on the child via
KACS_IOC_INSTALL. - The child execs the service binary. The kernel verifies the signature and sets the PIP fields.
Steady state #
After the graph is started, peinit transitions to its steady-state role: the lifecycle manager. It watches for service crashes (a child process dies; peinit receives SIGCHLD), restarts them per policy, handles shutdown signals (an administrator calling shutdown), and otherwise runs as a long-lived daemon.
The system is now "up" — services are running, authd is creating tokens for users who sign in, FACS and KACS are enforcing access control, audit events are flowing through KMES.
The fork-install-exec pattern #
The pattern is the most important thing peinit does, and it's worth pinning. The order of operations is deliberate:
- fork. Creates the child process. The child inherits the parent's token, file descriptors, and PSB. At this point the child is essentially a clone of peinit.
- Install token. The child gets its service-specific token via
KACS_IOC_INSTALL. This is the moment its identity is set to be the right thing for the service. - exec. The new program runs. The kernel verifies the binary's signature and sets the PIP fields.
The reason for this order: the service's token needs to be in place before the service's startup code runs — the service should be able to assume from its first instruction that it's running as the right identity. exec comes last because that is the moment the new program takes over; by then the identity has been decided.
The pattern is what every service is launched through. Variations are minor — the specific token differs per service — but the structural sequence is the same. (When per-service mitigations land in peinit — see the note above — they will slot in between install and exec, since mitigation flags must be on the PSB before the new binary runs.)
What peinit does not do #
A few clarifications:
- peinit mints only SYSTEM tokens. Minting the SYSTEM token for platform services via
kacs_create_tokenis peinit's normal, specified path — no authd interaction is needed for those. Every other identity is requested from authd, the source of truth for who-gets-what privileges. - peinit is not the only TCB process. authd, loregd, eventd, lpsd are also at TCB level. They have their own jobs.
- peinit does not enforce policy. DACLs, conditional ACEs, mount policies — the kernel runs AccessCheck and makes every access decision; peinit just launches processes with the right identities.
- peinit does not handle user sessions directly. Users sign in via authd; authd produces tokens; peinit launches the session's first process. The user's session is managed by authd, not peinit.
- peinit's service management is peinit, not a separate daemon. Start order, dependencies, restart policy, the service state machine, and the control interface are peinit's own responsibility — there is no service-manager process living alongside it. That model is substantial enough to have its own topic; this page does not cover it. See peinit.
When peinit fails #
If peinit crashes or exits, the kernel typically panics — losing PID 1 is a system-fatal condition in Linux. Peios's peinit is built to be very stable for this reason; its work is narrow and bounded.
If peinit cannot complete a startup step (a service won't launch, authd won't start), the deployment-specific behaviour depends on the configured policy. Typical responses:
- For a critical failure (authd won't start), boot fails. The system enters a recovery state — typically a console login as SYSTEM, with limited services running, enough for an administrator to diagnose and fix.
- For a non-critical failure (a single service won't start), peinit logs the failure, leaves the service unstarted, and continues with the rest of boot.
The failure-mode behaviour is configurable but conservative by default. peinit doesn't try to "work around" failures by relaxing security; if the configured set of mitigations can't be applied, the service doesn't start rather than starting unhardened.
authd handoff
Peios / Peios Security Fundamentals / Boot and trust establishment
The single most consequential transition during boot is the moment authd comes online. Before authd, every process is running on the SYSTEM token (inherited from init through peinit); identity is uniform and authority-maximal. After authd, processes start running as their actual principals — services as their service identities, users as themselves after they sign in. authd is the bridge between "kernel-direct bootstrap identity" and "real identities derived from the directory".
This page covers what authd does at startup, how the handoff from SYSTEM-everywhere to real-identities-everywhere happens, and what authd is responsible for in steady state.
What authd is #
authd is the authentication daemon. Its core responsibilities:
- Authenticate principals. When a user signs in, authd verifies their credentials against the directory (locally for standalone systems, against the domain's directory for domain-joined ones).
- Mint tokens. Once a principal is authenticated, authd produces a token reflecting their identity, group memberships, privileges, integrity level, claims. The token is what the principal's processes run on.
- Manage logon sessions. authd creates a session per authentication event, attaches the minted tokens to it, tracks lifecycle via the
logon-session-destroyedevent. - Distribute CAAP. authd reads central access policies from its source (registry or the domain's directory) and pushes them into the kernel's policy cache via
kacs_set_caap. - Resolve identity-related queries. authd is the answer to "what privileges does this user have?", "what claims should be on this token?", "is this user a member of this group?". These queries go to authd, which consults the directory.
authd is signed at TCB level, runs at pip_type = Protected, pip_trust = 8192, and is one of the PIP-protected processes. It is launched by peinit early in boot and runs continuously until shutdown.
authd's startup sequence #
When peinit forks-and-execs authd, the new process goes through several initialisation steps:
1. Verify own state #
Just like peinit at startup, authd verifies it is running in the expected state — PIP-protected, holding the expected token, with the expected mitigations applied. Anything else indicates the boot is broken; authd refuses to proceed.
2. Connect to the directory #
authd opens its connection to the directory. The connection mechanism depends on deployment:
- Standalone systems use loregd (the registry daemon). authd communicates with loregd over a Unix socket and reads policy from registry keys.
- Domain-joined systems use Samba 4. authd talks to a Samba 4 instance (locally or on another machine in the domain) for domain identity and policy resolution.
The directory is the source of truth for everything authd needs to know — principals, groups, privileges, CAAP, claims. authd's connection to the directory is established early because everything else depends on it.
If the directory connection fails (loregd not running, AD unreachable), authd cannot mint real tokens. The system continues on bootstrap tokens (everything as SYSTEM) until the directory becomes available. authd retries with exponential backoff; in the meantime peinit may proceed with whatever services don't need authd.
3. Populate the CAAP cache #
For each central access policy in the directory, authd parses it and pushes it via kacs_set_caap. The kernel's policy cache fills up with the deployment's CAAP.
This step matters for the recovery policy: until this step completes, every CAAP-referencing object resolves to the recovery policy (administrators and SYSTEM only). After this step, the object's actual policy applies.
CAAP population happens before authd advertises itself as ready. This is what makes the boot ordering safe — services that depend on CAAP being live can wait for authd to signal readiness; by then, CAAP is in place.
4. Begin serving authentication requests #
authd opens its socket — typically a Unix domain socket — and starts accepting connections from clients. The clients include:
- peinit, asking for tokens to assign to new services.
- Login frontends (console, SSH, terminal services) authenticating users.
- Services that handle their own re-authentication (rare, but possible).
Each authentication request follows a similar pattern:
- Client connects to authd's socket and presents credentials.
- authd verifies the credentials against the directory.
- authd resolves the principal's full identity (groups, privileges, claims) from the directory.
- authd creates a logon session via
kacs_create_session. - authd calls
kacs_create_tokento mint the token, passing the wire-format specification with everything resolved. - authd returns the token (or token fd) to the client.
The client now has a token reflecting the authenticated principal. The client can install the token on a child process (if it's peinit launching a service) or on itself (if it's a login frontend completing the user's sign-in).
5. Signal readiness #
authd writes a readiness signal to peinit — typically by closing a pre-existing pipe fd, by writing a sentinel value to a known location, or by reaching a state peinit can probe for. peinit, which has been blocked waiting for this, proceeds to launch the rest of the services.
The handoff in one paragraph #
The transition: before authd is ready, every userspace process is running on the SYSTEM token (or a SYSTEM-derived FilterToken variant). After authd is ready, peinit's subsequent service-launch operations assign each new service a token authd produces — derived from the directory, with the appropriate identity for the service. The system goes from "everything SYSTEM" to "services as themselves" gradually, over the course of however long peinit takes to launch the rest of userspace.
There is no single moment where the system "becomes secure" — it's a gradient. Even before authd, the kernel's access control is fully operational; SYSTEM just happens to be everywhere. As real tokens replace SYSTEM in process after process, the system's authorisation surface narrows. By the time login frontends are accepting users, every service is at its right identity, and users sign in to fresh sessions of their own.
authd in steady state #
After startup, authd's day-to-day work is:
- Authenticate users when they sign in. Each login produces a session and a token (or a pair of tokens for UAC-style elevation).
- Mint tokens for new service starts. When peinit needs a token for a service it's about to launch, authd produces one.
- Track session lifecycle. authd subscribes to the kernel's
logon-session-destroyedevent and uses it to release session-scoped state (Kerberos tickets, cached directory data). - Distribute CAAP updates. When a policy in the directory changes, authd re-pushes it via
kacs_set_caap. The kernel's cache is kept in sync with the directory. - Handle session revocation. When a user must be forcibly logged out, authd walks
/proc/*/tokento find tokens with the offendingauth_id, identifies the holding processes, and signals them to exit. This is the userspace-coordinated revocation pattern documented in Session lifecycle.
authd is a long-running daemon. It is not periodically restarted. Its uptime equals the system's uptime (modulo any administrative restarts in response to configuration changes).
What authd does not do #
A few clarifications:
- authd does not make access decisions. AccessCheck is in the kernel. authd produces inputs to it (tokens) but does not decide whether any specific access is granted.
- authd does not enforce CAAP. AccessCheck enforces CAAP; authd just distributes the policies into the kernel cache.
- authd does not authenticate every operation. Authentication is a sign-in event. Operations done after sign-in run against the token, not against authd. authd does not see most of the per-operation traffic.
- authd does not store passwords. Credential verification goes against the directory; authd is an intermediary. The directory (loregd's registry, or the domain's directory) is where the credentials live.
- authd does not implement the directory. authd consumes a directory; it doesn't be one. For standalone systems, loregd is the directory; for domain-joined, the domain's directory via Samba 4. authd talks to whichever is appropriate.
The cleanest mental model: authd is the converter between "directory state" and "kernel-resolvable identity". Everything else — access decisions, audit, lifecycle — happens around authd, not through it.
When authd fails #
authd is signed at TCB level, runs hardened, and is operationally critical. If authd crashes:
- New authentication requests fail. Users cannot sign in, peinit cannot get new tokens for new services.
- Existing tokens continue to be valid. Processes running on tokens authd already minted are unaffected.
- The kernel's policy cache continues to hold whatever CAAP authd had pushed. Updates to CAAP in the directory will not be reflected until authd is restarted.
peinit detects authd's exit (it's a child of peinit) and typically restarts it per policy. The restart goes through the same fork-install-exec pattern as the initial launch; the new authd instance reads the directory fresh and re-establishes the CAAP cache.
Sessions that authd had been managing are still associated with the old authd's auth_id values; the new authd doesn't "inherit" them but doesn't need to — the existing sessions continue running on the tokens already minted; the kernel knows they exist.
A failure that exceeds the restart policy's retry budget is treated as a critical failure; peinit may transition the system to a recovery state where administrative intervention is required.
Boot ordering implications #
Knowing what authd does has implications for boot ordering:
- Services that need CAAP must start after authd has populated the cache. peinit enforces this by waiting for authd's readiness signal before launching such services.
- Services that need real tokens must start after authd is up. Similarly waited for.
- Services that are happy with SYSTEM can start before authd. This is mostly registry, observability, and other infrastructure services.
- Login frontends must start after authd is up. They can't authenticate users otherwise.
The exact graph of dependencies is part of peinit's configuration. peinit knows which services depend on what and starts them in the right order.
The handoff is one-way #
A subtle but important property: the handoff from kernel-direct tokens to authd-managed tokens is one-way. Once authd is running and has taken over identity creation, the system does not revert to "kernel-direct only" without a full restart.
If authd crashes and restarts, it picks up where it left off (re-reading the directory, re-populating CAAP). It doesn't reset the system to SYSTEM-everywhere; that initial transition happens only once per boot.
The reason: the running services have already been assigned real tokens. Going back to SYSTEM would require reassigning every service's token, which would mean restarting every service. That's a system-wide reboot, not a recovery.
So the trust establishment chain is a one-time event per boot. It happens early; it sets up the system's authority distribution; it stays in place until shutdown. Reboot is the only way to redo it.
Where to go next #
For the init system that launches authd and waits on its readiness, read peinit at PID 1.
For the SYSTEM-everywhere state the handoff replaces, read Bootstrap tokens.
For the recovery policy that applies until the CAAP cache is populated, read Distribution and recovery.
Kernel invariants
Peios / Peios Security Fundamentals / Boot and trust establishment
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.
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.
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.
The required stack #
The LSM stack Peios expects is, in order:
landlock, lockdown, yama, 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.yamais permitted — it adds ptrace restrictions; complements PKM's ptrace gating.integrityis permitted — Linux's IMA/EVM mechanisms can run alongside PKM for the use cases they cover.pkmis the Peios module.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
mkirf
Peios / Peios Security Fundamentals / Boot and trust establishment
mkirf compiles an initramfs source tree into an initramfs image. The source tree is an ordinary directory — on a running Peios system that directory is /boot/initramfs/ — whose contents map 1:1 onto / inside the initramfs. The image is a single deterministic, gzip-compressed newc cpio archive that the bootloader loads into memory alongside the kernel.
mkirf [--watch] [--debounce SECS] [--exclude GLOB]... <src-dir> <out-file>
This page is the command reference. For what the initramfs stage is — prelude, the /boot/initramfs/ layout, and the handoff to the real root — see The initramfs stage; for how hooks declare their order, see Boot hooks. mkirf reads <src-dir> and writes <out-file>; it never writes back into the source tree, so the directory you inspect is always exactly what packages and you have put there.
What a build does #
Given a source tree, one build runs a fixed sequence:
-
Validate the layout.
<src-dir>must be a directory, must carry an executableinit(the initramfs PID 1 — a symlink is fine as long as it resolves within the tree), and must not already contain a hook sequence —hooks.seqorsystem/prelude/hooks.seq.<n>— whichmkirfgenerates. A missing or non-executableinit, or a stray sequence file, stops the build. -
Walk the tree. Every object beneath
<src-dir>becomes a cpio entry — regular files (with their executable bit preserved), directories, symlinks (target stored verbatim), character/block device nodes, and FIFOs. Sockets, and any other unsupported type, are rejected. Entries are sorted inLC_ALL=Cbyte order, which places every directory before its descendants — the order the kernel's unpacker requires. -
Resolve the hook order. Hooks are the regular files directly under
<src-dir>/usr/libexec/prelude/hooks.d/(packaged) or<src-dir>/lcl/libexec/prelude/hooks.d/(operator-placed), with the operator's copy winning when both hold the same file name. The legacy<src-dir>/hooks/is still read, and each hook found there is reported with the directory it should move to.mkirfparses each hook's# /// hookmetadata block, topologically sorts the resulting capability DAG, and bakes the execution order into generated manifests injected into the image at/system/prelude/hooks.seq.<n>. A missinghooks/directory simply means no hooks — the manifests are still written, empty, because prelude treats a missing sequence as a broken image rather than as "nothing to run".The version is in the file name, and
mkirfwrites every version it can express faithfully — currentlyhooks.seq.1(the flat resolved order) andhooks.seq.2(that order plus each hook's declarations).mkirfships inpeiosutilsand prelude ships in its own package, so an image can pair a newer writer with an older reader; naming the versions separately lets one image satisfy both, which a single file carrying an in-band version cannot. A version is dropped only when a future format carries something it can no longer project honestly.They live under
/system— the tier for derived content, alongsidesystem/retc— rather than/usr, which is the vendor tier no package ships these into, or/var/state, which is for mutable state rather than immutable build output. See Validation and errors below. -
Write atomically. The archive is written to a sibling temp file and
renamed into place, so a failed run never leaves a half-written image behind. On successmkirfprints the path, entry count, and byte size to stderr.
Early (pre-decompression) segments #
A reserved <src-dir>/++/ directory, if present, holds early initramfs segments — content the kernel consumes before it decompresses the main archive, namely CPU microcode and ACPI table overrides. Each immediate child of ++/ is one segment whose own contents map onto the cpio root (++/microcode/kernel/x86/microcode/GenuineIntel.bin becomes kernel/x86/microcode/GenuineIntel.bin); the segment directory name is a human label and never appears in the archive. Every ++/ entry must itself be a directory. The segments are merged into one uncompressed cpio prepended ahead of the gzip-compressed main archive. With no ++/ directory the output is exactly a single gzip member. mkirf stays format-agnostic here: it knows "early segments", never "microcode".
The deterministic-build guarantee #
The same source tree always compiles to the same image, byte for byte. This is what makes "did anything actually change?" a meaningful question and underpins later work such as signed boot artifacts. Determinism comes from normalising everything that would otherwise vary between builds:
- Ownership, inode numbers, and timestamps are all emitted as zero.
- Permission bits are normalised. The source tree is authoritative for file type and, for regular files, executability — nothing else. Read/write permission bits are not Peios's access mechanism, so they are flattened to a constant: directories
0755, symlinks0777, FIFOs and device nodes0644, regular files0755if executable and0644otherwise. - The gzip member carries mtime 0 and no embedded filename (the
gzip -nequivalent), at compression level 9. - The early region is byte-stable, with file payloads padded to a 16-byte boundary by widening the preceding entry's name field with NUL bytes.
Validation and errors #
mkirf will not produce an image from a source tree that cannot boot. A misconfiguration is caught when the image is built — on a running system where the message is easy to read — rather than as a mystery failure at the next boot. The checks:
- Layout.
<src-dir>is not a directory; noinit, orinitis not a regular file, or is not executable; a pre-existing hook sequence; a++entry that is not a directory; two++segments defining the same file. (Exit 1.) - Hooks. A malformed
# /// hookmetadata block (unclosed, a non-comment line inside it, a duplicate or unknown key, a badly-formed array), a dependency cycle, arequiresthat nothing supplies, or a capability that is both provided and contributed to (a capability is either a set of alternatives or a set of contributors, never both). Anafternaming a capability nothing supplies is not an error — that is what distinguishes it fromrequires. (Exit 1.) A hook with no metadata block at all is not an error — it is the escape hatch: it is scheduled last, in name order, andmkirfprints a warning so a forgotten block is visible. - I/O. Any filesystem read, write, or compression failure. (Exit 1.)
- Usage. A semantic invocation error clap cannot express — currently,
--watchwith<out-file>inside<src-dir>(see below). (Exit 2.)
Watch mode #
With --watch, mkirf stays resident: it builds once up front (so the watch never begins from a stale archive), then watches <src-dir> recursively and rebuilds after every change, debounced. This is what lets /boot/initramfs/ behave like an ordinary part of the filesystem — edit a hook and the initramfs is current again — rather than a build artifact an administrator has to remember to regenerate.
Watch mode is a foreground loop that runs until killed; supervising and restarting it is a service manager's job, not mkirf's. A rebuild that fails (say, a hook edited into a dependency cycle) is logged but does not stop the watch, so fixing the offending file recovers on the next change.
<out-file> must not live inside <src-dir>: each rebuild would write the image back into the watched tree and retrigger the watch endlessly. mkirf refuses this invocation up front (exit 2). The --debounce window (default 5 seconds) is the settle time — mkirf waits for the tree to be quiet for that long before rebuilding, so a burst of edits produces one rebuild rather than many.
Options #
| Option | Effect |
|---|---|
--watch | Stay resident and rebuild on every change to <src-dir>, after an initial build. Runs until killed. |
--debounce SECS | With --watch, the settle time before a rebuild. Default 5. A non-numeric value is a usage error. |
--exclude GLOB | Exclude paths (relative to <src-dir>) matching GLOB from the image. Repeatable. * and ? stay within a path segment; ** crosses separators. A matched directory is pruned with its whole subtree. A malformed glob is a usage error. |
The two positionals are both required:
| Positional | Meaning |
|---|---|
<src-dir> | The source tree; its contents map onto / in the initramfs. |
<out-file> | The output cpio.gz archive. |
Exit status #
| Code | Meaning |
|---|---|
0 | The image was built (or --help/--version was printed). |
1 | An operational failure — an invalid layout, an unresolvable hook set, or an I/O/compression error. |
2 | A usage error — a bad option, a missing positional, or --watch with <out-file> inside <src-dir>. |
See also #
- The initramfs stage — what
mkirf's output is for. - Boot hooks — the
# /// hookmetadata block and how order is resolved. - mkuki — wraps
mkirf's image, together with a kernel and command line, into a bootable UEFI unified kernel image.
mkuki
Peios / Peios Security Fundamentals / Boot and trust establishment
mkuki builds a UEFI unified kernel image (UKI): it takes a PE/COFF EFI stub and appends the kernel, the initramfs, and the kernel command line to it as named PE sections, producing a single EFI binary that UEFI firmware boots directly. Where mkirf produces the initramfs image, mkuki is the step that wraps that image — together with a kernel and a command line — into one bootable artifact. The two are the two halves of Peios' Dynamic Boot system.
mkuki --kernel PATH --initramfs PATH (--cmdline TEXT | --cmdline-file PATH) --out PATH
[--stub PATH] [--watch] [--debounce SECS]
mkuki --stub-info
The UKI section layout #
A UKI is an ordinary PE/COFF EFI executable — the stub — with three extra sections appended, which the stub reads at boot to find its payloads:
| Section | Content | Source |
|---|---|---|
.cmdline | The kernel command line, NUL-terminated. | --cmdline or --cmdline-file |
.linux | The kernel image. | --kernel |
.initrd | The initramfs cpio image. | --initramfs |
mkuki appends them in exactly that order. Each new section is marked as initialised, read-only data. The tool works at the PE level directly: it parses the stub's DOS/PE headers and section table, computes correctly-aligned virtual addresses and file offsets for the new sections, appends their data, updates the section count and the SizeOfImage/SizeOfHeaders fields, and — if the stub's header has no spare room for three more section entries — grows the header region and relocates the existing sections' file pointers to make space. A stub that is not a valid MZ/PE image, or whose optional header is malformed, is rejected.
The command line #
The command line is taken either literally from --cmdline or read from the file named by --cmdline-file (exactly one is required). Either way, mkuki trims trailing newlines and carriage returns and appends a single NUL terminator. A command line containing an embedded NUL byte is rejected.
The stub #
--stub names the EFI stub to append to. With no --stub, mkuki uses a stub bundled into the binary (the systemd EFI stub). mkuki --stub-info prints that bundled stub's provenance and exits, so you can see exactly what the default is without building anything.
Output and atomicity #
mkuki creates any missing parent directories of --out, writes the assembled image to a sibling temp file, and renames it into place, so an interrupted or failed build never leaves a half-written UKI behind. On success it prints the output path and byte size to stderr. Each of the three payloads must be non-empty; an empty .cmdline, .linux, or .initrd fails the build.
Watch mode #
With --watch, mkuki stays resident: it builds once up front, then rebuilds the UKI whenever an input changes — so the boot image tracks a new kernel, a freshly-repacked initramfs (mkirf's half of Dynamic Boot), or an edited command-line file with no manual step. Like mkirf's watch mode, it is a foreground loop that runs until killed; supervising it is a service manager's job, and a rebuild that fails (for example, a kernel caught mid-copy) is logged rather than fatal, so fixing the input recovers on the next change.
The watched inputs are --kernel, --initramfs, and — only if it is a --cmdline-file, since a literal --cmdline is static — the command-line file. mkuki watches each input's parent directory (non-recursively), not the file itself: this survives the atomic temp-and-rename writes that mkirf and mkuki both perform (which appear as a directory event a stale single-file watch would miss) and catches a versioned kernel being swapped in /boot. Because the watch is non-recursive, a write to an --out path nested deeper under a watched directory does not retrigger it — but an --out sitting directly in a watched input directory would, so mkuki refuses that invocation. The --debounce window (default 5 seconds) is the settle time before a rebuild.
Options #
| Option | Effect |
|---|---|
--kernel PATH | The kernel image; becomes the .linux section. Required (except with --stub-info). |
--initramfs PATH | The initramfs cpio; becomes the .initrd section. Required (except with --stub-info). |
--cmdline TEXT | The kernel command line, given literally; becomes the .cmdline section. Mutually exclusive with --cmdline-file. |
--cmdline-file PATH | Read the kernel command line from PATH instead. Mutually exclusive with --cmdline. |
--out PATH | The output UKI path. Required (except with --stub-info). |
--stub PATH | The PE/COFF EFI stub to append sections to. Defaults to the bundled systemd stub. |
--stub-info | Print the bundled stub's provenance and exit. |
--watch | Stay resident and rebuild the UKI whenever --kernel, --initramfs, or --cmdline-file changes. Runs until killed. |
--debounce SECS | With --watch, the settle time before a rebuild. Default 5. |
Exactly one of --cmdline or --cmdline-file must be given: supplying both, or neither, is a usage error.
Exit status #
| Code | Meaning |
|---|---|
0 | The UKI was built (or --stub-info, --help, or --version was printed). |
1 | A build or watch failure — a missing or unreadable input, an invalid stub, an empty payload, or an I/O error. |
2 | A usage error — a bad or missing option, or an invalid --cmdline/--cmdline-file combination. |
See also #
- mkirf — builds the
.initrdpayloadmkukiwraps. - The initramfs stage — what the bundled initramfs does once firmware boots the UKI.
Threads and processes
Peios / Using Peios / Threads and processes
A process is a program that is running. When a program starts, the system gives it a block of private memory that only it can see, a table of the files and other resources it holds open, and a name the system tracks it by. That running program, plus everything the system keeps for it, is a process. When the program finishes, its process goes away.
A process can also do several things at once by running more than one thread. A thread is a single line of execution — one sequence of steps the system is working through. A process always has at least one thread (its initial execution is its first thread), and it can start more. Every thread in a process shares the same private memory and the same open resources. What each thread keeps to itself is its own place in its sequence: each runs its own steps, at its own pace, possibly in parallel with the others.
The actors of the system #
Processes and threads are what carry out work on the system. Every action — opening a file, sending data over a network, starting another program — is performed by some thread. When the system decides whether an action is allowed, the question it answers is "is this thread allowed?" When it records that something happened, it records which thread did it.
A thread always acts as someone — a person, a service, or the system itself. Peios carries that identity along with the thread on an object called a token (see Tokens). Two facts matter here:
- Every thread is always acting as someone. There is no "nobody" state.
- When one process starts another, the new process begins acting as the same identity as the process that started it.
What a process has #
The things the system keeps for every process:
| A process has | What that means |
|---|---|
| Private memory | working space only this process can see; other processes cannot read it |
| Open resources | the files, connections, and other things it currently holds open |
| An identity | who it is acting as (carried on its token) |
| A place in a family tree | every process was started by another, so processes form a tree |
| A lifecycle | it is created, it runs, and it ends — and its end is always observed |
| One or more threads | the lines of execution doing its work |
This is not the full list — a process also carries other per-process state, such as its Process Security Block (PSB), which holds its security-related settings.
Where to start #
Continue with The process and thread model for how a thread and a process relate, and why the thread — not the process — is the more basic unit, with a "process" being one particular way of using them.
This topic also covers:
- Creating processes — how a process starts another, and what the new one begins with.
- Process lifecycle — how a process ends, and how the system cleans up after it.
- Process relationships and job control — the process tree, process groups, and sessions (distinct from the logon sessions in Logon sessions).
The process and thread model
Peios / Using Peios / Threads and processes
A running program has two separable parts: the threads, which run, and the process, which contains them.
The thread is the part that runs — one line of execution, a single sequence of steps the system works through. A machine with several cores runs that many threads genuinely in parallel, one per core; a single core runs many threads by switching between them. Which thread runs, on which core, and for how long is decided by the system's scheduler — a subject of its own. Some tools and logs call a thread a task; it is the same thing.
The process is the boundary around a group of threads. It does not run — it contains. A process holds the things its threads share: their private memory, their open files and connections, and their identity. Every thread inside one process sees the same memory and the same open resources; threads in two different processes do not. That separation is what stops one program from reaching into another's memory or files.
The thread does the work; the process is the shared space the work happens in. A program that does one thing at a time is a process with a single thread. A program that does several things at once is a process with several threads, all sharing one space.
Why the thread is the more basic idea #
Threads, not processes, are the fundamental unit: what the system runs is threads, and a "process" is the answer to the question "what does this thread share, and with whom?" Bundle some threads together so they share one memory, one set of open resources, and one identity, and that bundle is a process. Give a thread its own fresh, separate memory instead of sharing an existing one, and you have made a new process.
This matters because it explains creation. Making a new thread and making a new process are the same mechanism with a different answer to "how much does the new line of execution share with the one that made it?" Share everything, and the result is another thread in the same process. Share nothing, and the result is a new process. Everything in between is possible too. How the operation is actually performed is covered in Creating processes.
What threads share, and what they don't #
Within one process, every thread shares:
| Shared across threads | What that means in practice |
|---|---|
| The process's memory | One thread can read and change data another thread is using — cooperation is cheap, but concurrent access must be coordinated. |
| The open resources | A file one thread opens is usable by all of them. |
| The identity | By default every thread acts as the process's identity. |
Each thread keeps its own place in its sequence — which step it is on right now. That is the point of having more than one: each thread makes progress through its own work independently of the others.
There is one exception to the shared identity. A single thread can temporarily act as a different principal — for example, a service handling a request can act as the requester for that one piece of work, then revert. Only that one thread is affected, and only until it reverts. This is covered in Impersonation; here it is enough to know that "every thread in a process shares the process's identity" is the default, not an unbreakable rule.
Naming a process #
The system gives each process a number called its process ID, or PID, so that people and other programs can refer to it — to inspect it, signal it, or wait for it to finish. Threads are individually identifiable too, by a thread ID — see thread operations.
A PID is short and convenient, but it only names a process while that process exists. Once a process ends, its PID can later be reused for a completely different process. A PID tells you which process is which right now, but it is useless as a lasting record — two unrelated processes can hold the same PID at different times.
For that, every process also has a Process GUID (globally unique identifier): a name that is never reused, assigned to each process when it starts and fixed for the rest of its life. Where the PID is the convenient short handle for the moment, the Process GUID is the permanent one — it identifies that one specific process and no other, even long after it has ended. This is what lets a record of something that happened on the system name exactly the right process, with no chance of confusing two that happened to share a PID.
Both the PID and the GUID refer to a process. Neither says who the process is acting as — that is a separate question, answered by its identity rather than by any number or name.
Where to go next #
To see how a process or a thread is created — and what a new one starts out with — read Creating processes.
Creating processes
Peios / Using Peios / Threads and processes
Every process on the system was started by another process — a process that is already running has to create it. Creation is built from two operations, which combine to cover every case.
Splitting: one process becomes two #
A running process can split into a copy of itself. The original keeps going, and a second process appears beside it that begins as a near-identical copy. The original is called the parent and the copy the child.
The child starts with:
- Its own private memory, copied from the parent. From the moment of the split the two memories are separate — a change one process makes is invisible to the other.
- Copies of the parent's open resources — the files and connections the parent had open, the child has open too.
- The same identity — the child begins acting as the same principal as its parent. (It inherits the parent's token; how the token carries across a split is in Token lifecycle.)
It also gets things of its own that are not copied: a new PID, and a new Process GUID — the child is its own distinct process, not a continuation of the parent. A few pieces of per-process state start fresh rather than being inherited, too.
This operation is called fork.
Replacing: same process, different program #
A process can also replace the program it is running with a different one. It remains the same process — same PID, same Process GUID, same identity — but from that point on it runs entirely different code. Whatever the previous program was doing is discarded, replaced wholesale by the new one.
This operation is called exec.
Putting them together #
The two operations combine to launch a different program as a new process: a process splits, and the child immediately replaces itself with the program to run. This split-then-replace pair is how most programs are started — when you run a command, something forks and the child execs your command. There is also a single combined step, spawn, that does both at once for the common case.
Underneath, splitting is one setting of a more general operation, clone, that lets the creator choose how much the new line of execution shares with it. Share everything, and the result is another thread in the same process; share almost nothing, and the result is a new process — the two ends of the range from the process and thread model. Everyday code rarely uses the general form directly; it makes threads or spawns programs, which are particular settings of it.
The exact contract — every clone sharing flag, precisely what fork passes to a
child, and the variants of exec — is collected in the
process creation reference.
Getting hold of the new process #
When a process creates a child, it usually needs to keep track of it — to wait for it to finish, or to send it a signal later. For this it can hold a handle to the child: a reference to that one specific process, called a pidfd. It is the same kind of handle the system hands out for an open file.
A process can be given a pidfd for its child at the moment it creates it, or ask for one for a process that already exists. Either way the pidfd is tied to that one specific process and can never be mistaken for another, so waiting on it or signalling through it always reaches the intended target.
This is what makes a pidfd safer than a bare PID. A PID can be reused once the process it named has ended; code that holds a bare PID risks later acting on a completely different process that inherited the number. A pidfd never has that problem — it refers to the one process it was created for and nothing else, for as long as the program holds it.
Where to go next #
A process that has been created eventually ends. How it finishes, and how the system cleans up after it, is covered in Process lifecycle.
Process lifecycle
Peios / Using Peios / Threads and processes
Every process eventually ends. A process's life has three parts — it is created, it runs, and it finishes — and the system ensures that when it finishes, its result is collected and its resources are reclaimed.
The states it passes through #
While it exists, a process is in one of a handful of states, and the system moves it between them. Most of the time it is either running — executing, or ready to run the moment a core is free — or sleeping, paused while it waits for something to happen, such as data to arrive or a timer to fire. A sleeping process uses no processor time until what it is waiting for is ready; most processes spend most of their lives asleep.
A few states are worth knowing by name:
- Stopped — suspended (for example by job control when a job is paused at the terminal); it does nothing until told to resume.
- Traced — stopped under the control of a debugger that has attached to it, so the debugger can inspect and step it.
- Uninterruptible sleep — waiting on something the system will not interrupt, almost always a brief piece of disk or device I/O. A process here cannot be woken or even killed until the operation finishes; it normally lasts an instant, and a process stuck in it is a sign that hardware or a filesystem is stuck too.
- Zombie — finished, but still held as a result waiting to be collected (see below).
Once its result has been collected the process is gone, removed from the system entirely.
Two ways a process ends #
A process ends in one of two ways.
Most often it ends on its own: it finishes its work and stops. A process that ends this way leaves behind an exit status — a small record of how it went, usually just "succeeded" or "failed" (and, on failure, a number giving a rough reason). The act of a process ending itself is called exit.
The other way is that a process is stopped from outside — something tells it to stop, and it ends whether or not its work was done. (The messages that do this are signals, a subject of their own.) A process ended this way also leaves an exit status, marked to show it was stopped rather than finishing on its own.
Either way, a record of the process remains until its result is collected.
Collecting the result #
Whenever a child changes state — ends, is stopped, or resumes — the system
sends its parent a signal, SIGCHLD, prompting it to check. The parent
then reads the child's exit status. This is called waiting for the
process.
Between the moment a process ends and the moment its parent collects the result, the finished process is a zombie: no longer running, but with the system still holding its exit status so the parent can read it. Despite the name, a zombie is harmless — it does no work and uses almost nothing; it is a result waiting to be collected. The instant the parent collects it, the zombie is gone for good.
If a parent never collects, zombies accumulate — finished processes whose results no one read. That is a fault in the parent, not normal behaviour.
A parent holding a pidfd for its child can wait on it directly — the dependable way to be told the exact moment the child ends.
The exact calls — every wait variant, and how an exit status is read — are in the
process lifecycle reference.
When the parent ends first #
A parent does not always outlive its children. If a parent ends while one of its children is still running, that child becomes an orphan.
Orphans are not lost. The system's first process — PID 1, which on Peios is peinit — adopts every orphan and stands in as its parent from then on: when the orphan eventually ends, peinit collects its result, so no finished process is ever left uncollected. Adoption is automatic; the orphan keeps running unaffected, only with a new parent.
PID 1 is the catch-all, but a process can arrange to catch orphans within its
own subtree. By marking itself a subreaper (with
prctl(PR_SET_CHILD_SUBREAPER)), a process becomes the one that adopts any
orphan descended from it — its grandchildren and below — instead of letting
them reparent all the way up to PID 1. Service managers and container managers
use this to keep responsibility for everything they launched: when something
deep in their subtree loses its immediate parent, it reparents to the manager,
which still learns when it finally ends.
What the system cleans up #
When a process ends, the system reclaims what it was using. Its private memory is freed and its open files and connections are closed.
It also releases its identity. A process acts as a principal, carried on its token; when the process ends, its hold on that token is released. If other processes were sharing the same identity — for example, siblings from the same login — the identity lives on for them, and the system clears it away only once nothing is using it any more. The details are in Token lifecycle.
The process's PID becomes available for the system to assign to a future process. Its Process GUID is not reused — it remains a permanent marker of that one process, so records of what it did still point unambiguously back to it long after it is gone.
Where to go next #
Processes are arranged into a family tree and grouped together in ways that matter for running them. That is Process relationships and job control.
Process relationships and job control
Peios / Using Peios / Threads and processes
Every process is related to others in two different ways: by descent — who started whom — and by grouping — which processes are handled together when they are controlled at a terminal.
The family tree #
Every process was started by another, so every process has a parent: the process that created it. A process can refer to its parent by the parent's PID, sometimes called the PPID (parent process ID).
Follow the parents upward and they all lead back to the same place — PID 1, the first process, which the system starts at boot. Every other process descends from it, directly or through a chain of parents, so the processes on a running system form a single family tree rooted at PID 1. This is the same tree that makes orphan adoption work: when a process loses its parent, PID 1 takes over the role, as covered in the lifecycle.
A few processes at the very root of the tree are special. PID 1 is the first
process started at boot — on Peios, peinit — and the ancestor of all the rest; the
system protects it, refusing to kill it with any signal it has not chosen to handle,
because losing it would bring down the whole tree. Beneath the userspace processes
the kernel runs threads of its own: PID 0 is the idle process (what a core runs
when it has nothing else to do), and PID 2, kthreadd, is the parent of every
kernel thread. The kernel's threads — among them the worker threads (kworker)
that run deferred work and the threads that service hardware interrupts — appear in
a process listing as the system's own work rather than as programs loaded from disk.
Process groups #
Several processes often do one job together. In a pipeline — the output of one command feeding into the next — each command is its own process, but they form one job: you stop, pause, or resume the whole job at once, not a piece at a time.
To make that possible, related processes are collected into a process group. A process group is a set of processes treated as a unit, so that an action — most often a signal, like the one that stops a job — can be delivered to all of them together instead of one by one.
Sessions and the terminal #
Process groups are themselves collected into a larger unit: a session, tied to the terminal the work is running at.
A terminal can only take input for one job at a time, so within the session one process group is the foreground group: the one connected to the keyboard, the one typing reaches, the one a stop-or-interrupt keystroke acts on. Any other groups are in the background, running without holding the keyboard. Moving a job between foreground and background — and starting, stopping, and resuming jobs — is job control.
A different kind of "session" #
The word "session" has two meanings here, and they are unrelated:
- A job-control session, described above, organises processes at a terminal — which groups share a terminal, which one is in the foreground. It says nothing about who anyone is.
- A logon session records one sign-in — a single authentication event — and ties together every identity token that came from it. It is part of the identity model, covered in Logon sessions.
They often line up in practice — signing in at a terminal creates a logon session, and the processes run there share a job-control session — but they are separate things answering separate questions: "which processes share this terminal?" versus "where did this identity come from?"
Where to go next #
Beyond its place in the tree and its groups, every process carries a bundle of its security-related settings — its Process Security Block. That is The Process Security Block.
The Process Security Block
Peios / Using Peios / Threads and processes
A process's identity — who it is acting as — is carried on its token, and can change moment to moment, since a thread can impersonate another principal. A process has a second aspect that is independent of identity: what it is. What program is it running? How trusted is that program? How hardened is it against attack? Who is allowed to operate on it?
Those facts are gathered in one place — the Process Security Block, or PSB. Every process has one. Where the token answers who this process is acting as, the PSB answers what this process is. Unlike the token, the PSB never changes when a thread impersonates: impersonation changes who, never what.
What the PSB holds #
- Its permanent name. The process's Process GUID — the never-reused identifier — lives on the PSB.
- How trusted its program is. When a process starts running its program, the system checks the program's cryptographic signature and records from it how trusted the program is. This is the process's PIP (Process Integrity Protection) label, and it decides which other processes are allowed to inspect, signal, or interfere with this one. The barrier is based on what program is running, not on who is running it: even a fully privileged process cannot disturb a more-trusted one. The full treatment is Process integrity protection.
- How it is hardened. A set of mitigations — restrictions the process carries on what it may do with its own memory and code, so that a bug or injected code has far less room to do harm. They can only ever be tightened, never loosened. The catalog and rules are Process mitigations.
- Who may operate on it. Every process has its own security descriptor — the rules for who is allowed to act on the process itself: inspect it, signal it, and so on. It lives on the PSB alongside the rest.
A few more specialised settings live here too — a process can be marked so that it may no longer create children, for instance — but those four are the core.
Inspecting and managing the PSB #
Because the PSB is where a process's trust level and hardening live, there is a
dedicated tool for working with it: the psb command, which inspects a
process's PSB and manages its mitigations and PIP mode.
Where to go next #
The two largest parts of the PSB each have a topic of their own: Process integrity protection, for the trust label that governs which processes may interfere with which, and Process mitigations, for the self-hardening flags and how they are applied.
Process creation reference
Peios / Using Peios / Threads and processes
This is the detailed counterpart to
Creating processes. That page
explains the ideas; this one gives the exact contract — what each call does, what a
new process inherits, and what each clone sharing flag controls.
fork #
fork() creates a new process by duplicating the caller. The child is a
near-identical copy that runs independently from the point of the call.
The child inherits:
- a private copy of the parent's memory, made lazily (copy-on-write), so the duplication is cheap and changes on either side stay invisible to the other
- copies of the parent's open file descriptors — each refers to the same open file description, so file offset, status flags, and signal-driven-I/O settings are shared between parent and child
- open directory streams and open message-queue descriptors
- open-file-description locks (
flock, OFD locks) through those shared descriptors - the parent's signal dispositions (the handler set for each signal)
- the file-creation mask (umask), the current directory, and the root directory
- resource limits and the timer-slack value
- the parent's identity — it begins acting on the same token (see Token lifecycle)
- the parent's mitigations and protection, carried on its PSB
The child does not inherit:
- the parent's PID — it receives a new PID and a new Process GUID
- memory locks (
mlock,mlockall) - memory regions marked
MADV_DONTFORK(absent in the child) orMADV_WIPEONFORK(present but zeroed) - process resource usage and CPU-time counters — reset to zero
- pending signals — the child's set starts empty
- semaphore adjustments (semadj)
- process-associated record locks (POSIX
fcntllocks — distinct from the OFD andflocklocks above, which are shared through the inherited descriptors) - timers (
setitimer,alarm,timer_create) - outstanding asynchronous I/O
- directory-change notifications (
dnotify) - the
PR_SET_PDEATHSIGparent-death-signal setting (reset) - I/O-port access permissions (
ioperm)
The child's termination signal — what its parent is notified with when it ends —
is SIGCHLD.
The child is created with a single thread — the one that called fork(). If
the parent had other threads, they do not exist in the child.
vfork #
vfork() is a lightweight variant of fork() for the one case where the child
will immediately replace itself with another program. It differs in two ways:
- the caller is suspended from the call until the child either
execs or exits; - the child runs in the parent's memory — no copy is made — until that point.
Because the two share memory and the parent is frozen, the child must do almost
nothing first. The contract: the child must not return from the function that
called vfork(), must not modify any variable other than the one holding the
return value, and must not call any other function before a successful exec or
_exit. Doing otherwise is undefined behaviour.
vfork is an optimisation for the create-then-replace pattern. Ordinary code
should use posix_spawn (below) rather than reach for it directly.
clone and clone3 #
clone() is the general creation call. fork and thread creation are both
clone with particular flag sets; clone exposes the choice directly. The caller
gives a set of flags controlling what the new task shares with the creator, a
stack for the new task, and locations for thread-ID and thread-local-storage
bookkeeping.
clone3() is the modern form, taking a structure instead of positional arguments
so it can grow new fields over time without changing the call:
| Field | Purpose |
|---|---|
flags | The sharing flags (below). |
pidfd | Where to store a pidfd for the new child. |
child_tid / parent_tid | Where to record the new thread's ID, in the child's and the parent's memory. |
exit_signal | The signal delivered to the parent when the child ends. |
stack / stack_size | The new task's stack and its size. |
tls | The new task's thread-local-storage area. |
set_tid / set_tid_size | Request specific thread IDs for the new task — a privileged operation (see below). |
cgroup | Place the child directly into a resource group at creation (see resource management). |
The structure is size-versioned: the caller passes the size it knows, the kernel reads up to the size it knows, and any unknown trailing fields must be zero. This is how new fields are added without breaking existing programs.
Requesting a specific thread ID (set_tid) is a privileged operation — it
exists for checkpoint-and-restore tools that must recreate a process with its
original ID. The privilege required follows the
capability model.
clone sharing flags #
Each flag makes the new task share something with its creator instead of getting its own copy. The flags that build threads and control creation:
| Flag | Effect |
|---|---|
CLONE_VM | Share memory rather than taking a private copy. |
CLONE_FS | Share filesystem context — current directory, root directory, file-creation mask. |
CLONE_FILES | Share the open-descriptor table, so opening or closing a descriptor in one is seen by the other. |
CLONE_SIGHAND | Share the table of signal handlers. Requires CLONE_VM. |
CLONE_THREAD | Put the new task in the same process (thread group) as the creator. Requires CLONE_SIGHAND. |
CLONE_SETTLS | Set the new task's thread-local-storage area. |
CLONE_SYSVSEM | Share the System V semaphore-adjustment list. |
CLONE_IO | Share the I/O context, so the two are accounted as one for disk I/O. |
CLONE_PIDFD | Return a pidfd for the new child. |
CLONE_PARENT | Make the new task a sibling of the creator — its parent becomes the creator's parent, which is also what is signalled when the task ends. An init process (PID 1) cannot use this flag, as it would create unreapable zombies. |
CLONE_PARENT_SETTID / CLONE_CHILD_SETTID | Record the new task's ID in the parent's / child's memory. |
CLONE_CHILD_CLEARTID | Clear the recorded thread ID and wake a waiter when the task exits — the mechanism behind joining a thread. |
CLONE_UNTRACED | Prevent a tracer from forcing tracing onto the new child. |
CLONE_VFORK | Suspend the creator until the child execs or exits (the vfork behaviour). |
A thread is CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD
(plus TLS and thread-ID bookkeeping) — everything shared, same process. A fork
is the opposite end, with almost nothing shared.
Being in one thread group has consequences beyond shared memory. Every thread
returns the same PID from getpid (the thread-group ID), and signal actions are
process-wide — an unhandled fatal signal delivered to any thread ends them all —
though each thread keeps its own signal mask. A thread that ends does not notify the
thread that created it and cannot be collected with a wait call; only once every
thread in the group has ended is the process's parent sent SIGCHLD. The new thread
shares the creator's parent, and CLONE_THREAD requires CLONE_SIGHAND (and hence
CLONE_VM).
Two further flag groups select separation rather than sharing, each documented where it belongs:
- The
CLONE_NEW*flags create namespaces — separate views of system resources (mounts, process IDs, networking, and more), covered under namespaces. CLONE_INTO_CGROUPplaces the new process into a resource group at creation, covered under resource management.
CLONE_DETACHED is a historical flag with no effect and is ignored.
The exec family #
An exec replaces the program running in the current process with a different one.
The process keeps its PID, its Process GUID, and its identity — only the program
changes (see Token lifecycle for the identity detail).
| Call | What it does |
|---|---|
execve | Replace the current program with the one at a given path. |
execveat | Replace it with a program named relative to an open directory, or — with AT_EMPTY_PATH — by an open file descriptor directly. AT_SYMLINK_NOFOLLOW refuses a final symbolic link. |
fexecve | Replace it with the program referred to by an open file descriptor (a thin wrapper over execveat). |
What survives an exec #
A successful exec keeps the process itself but replaces the program it runs.
Preserved across the call:
- the PID, the parent PID, and the Process GUID
- the process's identity (its token), its process group and session, and its controlling terminal
- open file descriptors — except those marked close-on-exec, which are closed
- the current directory, root directory, file-creation mask, and resource limits
- pending signals, and the dispositions of signals that were ignored or left at their default
Reset or discarded:
- all memory — the program's mappings, stack, heap, and data are replaced; memory
locks are dropped and
MADV_*region markings are gone - every thread except the one calling
exec— the new program starts single-threaded - handlers for caught signals — reset to the default; the alternate signal stack is dropped
- attached System V shared memory, POSIX shared-memory mappings, open POSIX message-queue descriptors and named semaphores, and in-process synchronisation objects (mutexes, condition variables)
- POSIX timers, outstanding asynchronous I/O, open directory streams, and registered exit handlers
- the floating-point environment, and the process name (set to the new program)
What an exec does to a process's identity when the program file is marked to run
as another principal is not the Linux set-user-ID model — Peios handles that
through the token, covered in
setuid and uid0.
Whether a program is allowed to run at all is a separate, execution-policy question — covered under binary signing — not part of these calls.
posix_spawn #
posix_spawn() is the standard library function for the create-then-replace
pattern: it makes a new process and runs a named program in it with a single call,
with controls for arranging the child's file descriptors and a few attributes
first. It is built on the calls above and is the recommended way to launch a
program, in preference to assembling fork/vfork and exec by hand.
Process handles (the pidfd family) #
A pidfd is a file descriptor referring to one specific process — the reliable
handle introduced in
Creating processes. CLONE_PIDFD
hands one back at creation; a few calls work with them afterwards:
| Call | What it does |
|---|---|
pidfd_open | Obtain a pidfd for a process that already exists, given its PID. |
pidfd_send_signal | Send a signal to the process through its pidfd — with no risk of the PID having been recycled for a different process in between. |
pidfd_getfd | Duplicate one of the target process's open file descriptors into the caller. |
pidfd_getfd reaches into another process, so it is not something any process may
do to any other: it is gated by the right to act on the target, governed by that
process's security descriptor.
See also #
- Creating processes — the conceptual counterpart to this page.
- Process lifecycle reference — ending a process and collecting its result.
- Thread operations reference — per-thread calls: TIDs, exit notification, TLS.
Process lifecycle reference
Peios / Using Peios / Threads and processes
This is the detailed counterpart to Process lifecycle. That page explains how a process ends and is cleaned up; this one gives the exact calls — how a process terminates, what its exit status encodes, and how a parent collects it.
Ending a process #
A process can end itself in a few ways, differing in how much tidying happens first:
| Call | What it does |
|---|---|
exit() | The orderly end. Runs every handler registered with atexit/on_exit (in reverse order of registration), flushes and closes the standard I/O streams, removes temporary files, then terminates. |
_exit() / _Exit() | The immediate end. Terminates at once — no registered handlers run and the standard I/O streams are not flushed — though open file descriptors are still closed. Used when a process must stop without running cleanup, such as a child after a failed exec. |
exit_group() | Ends the whole process — every thread in it — not just the calling one. This is what ordinary termination uses: a normal exit(), and a return from the program's entry point, both end the entire process. |
Returning from the program's entry point is equivalent to calling exit() with the
returned value.
At the lowest level the raw _exit system call ends only the calling thread; the
library _exit and ordinary termination route through exit_group so the whole
process ends. Ending one thread while the rest of the process keeps running is the
unusual case.
A process can also be ended from outside, by a signal rather than by a call of its own — covered under signals. Either way, it leaves an exit status behind.
The exit status #
When a process ends it leaves a small exit status for its parent to read. It records one of two outcomes:
- Ended on its own — carries an exit code: the low 8 bits of the value passed to
exit/_exit(so 0–255, with 0 conventionally meaning success). - Ended by a signal — carries the number of the signal that ended it, plus a flag for whether the process produced a core dump.
The status is a packed value, read not directly but through a set of macros:
| Macro | Tells you |
|---|---|
WIFEXITED | the process ended on its own — if so, WEXITSTATUS gives its exit code |
WIFSIGNALED | the process was ended by a signal — if so, WTERMSIG gives the signal number and WCOREDUMP whether it dumped core |
WIFSTOPPED | the process was stopped (not ended) by a signal — WSTOPSIG gives which |
WIFCONTINUED | the process was resumed by a continue signal |
Collecting the result #
A parent collects a finished child's status — and in doing so clears the zombie — with one of the wait calls. They differ in which child they target and how much they report:
| Call | What it offers |
|---|---|
wait | Wait for any child to end; return its PID and status. The simplest form. |
waitpid | Wait for a chosen target (see below). Options adjust the behaviour: WNOHANG returns immediately if nothing is ready (a poll rather than a wait), and WUNTRACED/WCONTINUED also report children that have stopped or resumed, not only ended. |
waitid | The most precise form. It can target a child by a pidfd (P_PIDFD) — the reliable handle from Creating processes — as well as by PID, by process group, or any child. It reports through a structured record (which child, and what happened: ended, killed, core-dumped, stopped, continued). Which kinds of change to wait for are chosen explicitly — WEXITED for children that have ended, WSTOPPED for those stopped by a signal, WCONTINUED for those resumed — and its WNOWAIT option peeks at the result while leaving the child collectable again later. |
wait4 | Like waitpid, and additionally returns the child's resource usage — processor time consumed, peak memory, and so on. |
waitpid's target is chosen by a single number: a positive value waits for that
exact PID; -1 for any child; 0 for any child in the caller's own process group;
and a value below -1 for any child in the process group whose ID is its absolute
value.
Two further options matter for threads and other clone-created children, which may
not notify their parent with SIGCHLD the way an ordinary child does: __WCLONE
waits only for such clone children, and __WALL waits for every child regardless
of type.
See also #
- Process lifecycle — the conceptual counterpart to this page.
- Process creation reference — fork, clone, exec, and the pidfd calls.
Thread operations reference
Peios / Using Peios / Threads and processes
This is the thread-level companion to the process and thread model — the operations that act on an individual thread rather than on the process as a whole.
Thread IDs #
Every thread has its own thread ID (TID), returned by gettid. Within a
multi-threaded process all threads share one PID — the value getpid returns,
which is really the thread-group ID (TGID) — but each thread's TID is unique.
In a single-threaded process the TID, PID, and TGID are all the same number.
The TID is how the system targets one specific thread rather than the whole
process: sending a signal to a single thread, setting one thread's CPU affinity, or
addressing it under /proc. It names a thread the way a PID names a process — and,
like a PID, it is a reference, not an identity. What a thread is acting as is its
token, never its TID.
The TID is also distinct from the opaque handle a threading library returns when it creates a thread; that is a library-level identifier, not the kernel's TID.
Thread-exit notification #
A thread can have the kernel notify waiters when it exits — the mechanism threading
libraries build thread join on. set_tid_address sets a clear-tid address for
the calling thread (and returns its TID). When that thread later terminates, if it
shares memory with other threads, the kernel writes 0 to the integer at that address
and wakes one thread waiting on it (a single futex wake).
A thread waiting for another to finish blocks on that address; the kernel clears it
and wakes the waiter at the exact moment the other thread ends. This is the same
machinery as the CLONE_CHILD_CLEARTID flag in
process creation — that
flag arranges the clear-tid address at thread creation, where set_tid_address sets
it afterwards.
Thread-local storage #
Thread-local storage (TLS) gives each thread its own private copy of a variable — a value that is per-thread rather than shared across the process, even though the threads share the same memory. The per-thread data lives in an ordinary block of memory; what makes it per-thread is a register pointing at this thread's block, so the same code reaches a different copy depending on which thread is running.
The kernel's part is maintaining that per-thread pointer. It is set when the thread
is created (the CLONE_SETTLS flag carries the address) and, on x86-64, a running
thread can change it through arch_prctl with ARCH_SET_FS (the older
set_thread_area serves the same purpose on 32-bit). Everything above that — how
thread-local variables are laid out and reached in code — is handled by the compiler
and the threading library.
See also #
- The process and thread model — the conceptual counterpart to this page.
- Process creation reference — the clone flags that create threads and arrange TLS and clear-tid addresses.
peinit
Peios / Using Peios / Services & jobs
peinit is the init system of Peios — the first userspace process, running at PID 1 for the lifetime of the machine. It is a single-threaded program with one job: manage services, from the first one started at boot to the last one stopped at shutdown. Every supervised process on a Peios system — a long-running daemon, a one-shot setup task, a health check, a scheduled job — is started, watched, and reaped by peinit.
What makes peinit its own thing is that identity is built in. peinit does not just launch processes; it launches them as someone. Each service runs under a KACS token that decides what it can reach, and each service is itself a securable object with a security descriptor that decides who may start, stop, or query it. Service management and access control are the same system, not two systems bolted together.
This page sketches what peinit is responsible for, names the handful of ideas that make it unlike other init systems, and points at the pages that cover each one.
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.
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, an ad-hoc run — 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.
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.
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 all service definitions synchronously once, during boot, 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.
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). |
| JFS | Delivering ad-hoc job submissions — a service asking peinit to run a process on a client's behalf. |
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.
Where to start #
If you are configuring a system, start with Defining a service — what a service definition is, where it lives, and the rule that explains why some changes take effect immediately and others wait for a restart.
If you want to operate a running system — start, stop, query, and troubleshoot services — go to Controlling services for the command set and The service lifecycle to read what a status actually tells you.
If you care about what runs as whom, read Service identity and privileges and Who can manage a service — the two independent halves of the security model.
If you are investigating boot behaviour, Boot and boot modes covers Full, Safe, and Recovery modes and the escalation between them.
And when something is wrong, Troubleshooting peinit works backward from the symptom.
Defining a service
Peios / Using Peios / Services & jobs
A service is defined by a single key in the registry, under Machine\System\Services\<name>. The key's name is the service name, and the typed values inside it are the definition — the binary, who it runs as, what it depends on, how it is supervised. There is no file under /etc; there is a registry key.
peinit reads these definitions in two situations: once at boot, to build the service graph, and on demand, when an administrator starts a service or runs reload-config. Between those reads it works from an in-memory copy. That last fact is the source of the most common "why didn't my change take effect?" question, and the second half of this page is devoted to it.
Service names #
A service name must be made of characters from [A-Za-z0-9._-] and be 1–128 bytes long. Anything else is a validation error. Two characters are pointedly excluded:
/— names map directly onto cgroup ids and registry key names, and a slash would be ambiguous in both.:— reserved for peinit-internal synthetic names (for example, the way a hook job is labelled).
The name is how you refer to the service everywhere: in peiosctl commands, in another service's dependency list, in a ServiceSecurity descriptor, and in the per-service SID derived from it.
The definition schema #
A definition is a set of typed registry values. Rather than list all of them in one wall of rows, the tables below group the fields by what they are for, with a pointer to the page that explains each group in depth. Every field is optional unless noted; the only hard requirement is ImagePath. The full type-and-default catalog lives in the Registry key reference.
What to run — the binary and its execution context.
| Field | Default | Purpose |
|---|---|---|
ImagePath (required) | — | Absolute path to the service binary. |
Arguments | — | Argument list passed to the binary. |
WorkingDirectory | / | Working directory for the process. |
Environment | — | KEY=VALUE pairs added to the environment. |
RuntimeDirectories | — | Private directories created under /run just before the process starts. See The execution environment. |
LimitNOFILE, LimitCORE | — | RLIMIT_NOFILE / RLIMIT_CORE. |
What kind of service — type and readiness. See Simple and Oneshot services.
| Field | Default | Purpose |
|---|---|---|
Type | Simple | Simple (long-running) or Oneshot (run-to-completion). |
Readiness | Notify | Notify (READY=1 via sd_notify) or Alive (ready when the process exists). Ignored for Oneshot. |
RemainAfterExit | 0 | Oneshot only — stay in Completed after a successful exit. |
SuccessExitCodes | — | Non-zero exit codes to treat as success. |
When to start — triggers and conditions. See Triggers and timers.
| Field | Default | Purpose |
|---|---|---|
Triggers | — | boot and/or timer:<schedule>. Absent = demand-only. |
Disabled | 0 | If 1, triggers must not activate the service (manual start still allowed). |
SafeMode | 0 | If 1, attempt to start in Safe mode. |
Conditions, Asserts | — | Start-time checks. A failed condition skips; a failed assert fails. |
Who it runs as — identity and privileges. See Service identity and privileges.
| Field | Default | Purpose |
|---|---|---|
Identity | LocalService | Principal name or SID for the service token. |
RequiredPrivileges | — | Privilege allow-list; everything else is stripped from the token. |
HookIdentity | service's Identity | Identity for ExecStartPre/ExecStartPost hooks. |
How it relates to other services — dependencies. See Dependencies and ordering.
| Field | Purpose |
|---|---|
Requires | Hard dependencies — must be satisfied first; their failure fails this service. |
Wants | Soft dependencies — started first if present, but optional. |
BindsTo | Runtime coupling — if the target stops, this stops too. |
Conflicts | Mutual exclusion — starting this stops the named services. |
OnFailure | Service to start when this one enters Failed. |
How it is kept alive — supervision and health. See Keeping services running.
| Field | Default | Purpose |
|---|---|---|
ErrorControl | Normal | Normal (stay Failed) or Critical (sync + reboot on irrecoverable failure). |
RestartPolicy | OnFailure | Never / OnFailure / Always. |
RestartMaxRetries, RestartWindow, RestartDelay | 5 / 120 / 1 | Restart budget, the window of health that resets it, and the backoff base. |
HealthCheck, HealthCheckInterval, HealthCheckTimeout, HealthCheckRetries | — / 30 / 5 / 3 | Active health-check command and its timing. |
WatchdogTimeout | 0 | Expected interval between WATCHDOG=1 pings; 0 disables. |
The transition phases — hooks and timeouts. See The execution environment and The service lifecycle.
| Field | Default | Purpose |
|---|---|---|
ExecStartPre, ExecStartPost | — | Commands run before the binary / after readiness. |
ExecReload | (SIGHUP) | Reload command or signal:<NAME>. |
StartTimeout, StopTimeout | 30 / 10 | Seconds for the whole start sequence / between SIGTERM and SIGKILL. |
The remaining knobs — scheduling, notify, fds, metadata.
| Field | Default | Purpose |
|---|---|---|
TimerPersistent, TimerJitter | 1 / 0 | Catch up missed timer runs after reboot / random delay per firing. |
NotifyAccess | Main | Who may send sd_notify messages (only Main is supported). |
FdStoreMax | 0 | Size of the per-service fd store; 0 disables it. |
ServiceSecurity | inherit | Security descriptor controlling who may manage the service. |
DisplayName, Description | — | Human-readable labels for status output. |
Forward compatibility #
The schema version lives at Machine\System\Services\SchemaVersion (currently 1). peinit is deliberately forward-compatible:
- Unknown values are ignored. A definition written for a newer peinit does not break an older one.
- A newer schema version does not block boot. peinit logs a warning and continues.
- The schema only grows. New capability arrives as new optional fields, never as a breaking change to an existing one.
One defensive rule cuts the other way: a known field must not appear more than once in a collected definition. A duplicated known field is a validation error, even though the registry would ordinarily give you at most one value per name.
peinit works from a snapshot, not the live registry #
Here is the idea that explains most surprises. peinit does not re-read the registry every time it touches a service. It reads definitions at well-defined moments and operates on an in-memory model in between.
Two layers of snapshotting stack on top of each other:
- Boot generation. At the start of boot, peinit reads all definitions, builds the dependency graph, and validates it. The whole boot runs against that one snapshot. Changes made during boot — by an install script, a post-hook — do not perturb the boot in progress.
- Activation generation. When peinit starts a specific service, it snapshots that service's definition for the entire start. Pre-exec hooks, the token request, the readiness timeout, the first health checks — all use the values captured at activation. Edit a field while the service is
Starting, and the edit waits for the next start.
peinit learns about registry edits through change notifications: it subscribes to Machine\System\Services\ and Machine\System\Init\ at boot, and processes events in its event loop at a time of its choosing. If the notification queue overflows — a bulk admin operation, a flurry of scripted writes — peinit detects the overflow marker and does a full reload-config to resynchronise. You never have to think about the queue; you do have to know that a change is picked up, not pushed, and that when it takes effect depends on the field.
Field mutability: when a change takes effect #
Every field falls into one of four mutability classes. This table is the one to keep handy.
| Class | When a change takes effect | Fields |
|---|---|---|
| Immutable at runtime | Next restart only. | ImagePath, Type, Identity, RequiredPrivileges, ErrorControl |
| Apply on next start | Next start or explicit graph reload — not while running. | Requires, Wants, BindsTo, Conflicts, OnFailure, Conditions, Asserts |
| Hot-reloaded | Next relevant event, no restart. | ServiceSecurity (next control request) |
| Reloadable at runtime | Next relevant operation (restart, health-check cycle, …), no restart. | Arguments, SuccessExitCodes, all timeout/retry values, the health-check fields, RestartPolicy, Environment, WorkingDirectory, the hooks, Readiness, NotifyAccess, LimitNOFILE/LimitCORE, FdStoreMax, SafeMode, DisplayName, Description |
The practical reading: changing what a process is or runs as (ImagePath, Identity, Type, privileges, ErrorControl) is fundamental enough that it only applies when a fresh process starts — you must restart. Changing policy that peinit consults each time it acts (timeouts, restart behaviour, health checks) is picked up the next time peinit acts. And ServiceSecurity is special: it is re-read on every control request, so an access-control change takes effect on the very next command without touching the running service.
For inactive services the rules are simpler, because there is no running process to protect: a new service entry becomes available once the change notification is processed; a changed timer arms on the next evaluation; a changed dependency takes effect on the next start.
Removing a service definition #
Deleting a definition from the registry does not kill a running instance. peinit learns of the removal through the same notification path and behaves according to whether the service is running:
- Not running (Inactive, Failed, Completed, Skipped, Abandoned): peinit discards the in-memory entry immediately. There is nothing to supervise.
- Running (Active, Starting, Reloading, Backoff, Stopping): the running process is a job, and a job outlives its definition. peinit marks the entry definition-removed, keeps the cached definition only to finish supervising the existing instance, and does not restart it when it exits. Once it exits, the entry — and any stored fds — are discarded.
While an entry is definition-removed it keeps satisfying its dependents (it is still running), stop still works so you can drain it cleanly, but start, restart, and reload are rejected with UNKNOWN_SERVICE — there is no definition to start from. A status query reports it with a definition_removed: true flag so the draining instance is never invisible.
Where to start #
To understand the Type and Readiness fields and the Simple/Oneshot split, read Simple and Oneshot services.
To understand how a definition becomes a running, supervised process — and what each state in a status output means — read The service lifecycle.
For the complete type-and-default catalog of every registry key peinit reads, see the Registry key reference.
Simple and Oneshot services
Peios / Using Peios / Services & jobs
A service's type answers one question: does the process keep running, or does it run once and exit? peinit supports exactly two answers — Simple and Oneshot — set by the Type field (default Simple). The type is independent of when the service starts; a Oneshot can be boot-triggered, timer-triggered, or demand-only, exactly like a Simple service.
Simple services #
A Simple service is a long-running daemon. peinit forks, installs the token, and execs the binary. The process is the service: while it runs, the service is running; when it exits, the service has stopped. This is the default and covers the large majority of services — registryd, authd, sshd, application daemons.
The interesting question for a Simple service is when does it count as started — the moment its dependents are allowed to begin. That is the readiness model, set by the Readiness field.
Readiness: Notify vs Alive #
| Readiness | Ready when… | Use for |
|---|---|---|
| Notify (default) | The service sends READY=1 via sd_notify. | Any service that has meaningful startup work — opening a socket, loading state, connecting to a backend. The dependent genuinely should wait. |
| Alive | The process exists — readiness is immediate. | Services with no startup handshake, where "the process is up" is as good a signal as you will get. |
The distinction matters because it is a promise to dependents. With Notify, a service that declares itself a Requires target is telling peinit "do not start anything that needs me until I say READY=1." With Alive, peinit can only promise that the process was forked — not that it is functional.
Once ready, a Simple service transitions to Active and stays there until its process exits or it is stopped. What happens on exit — clean exit, crash, restart — is the subject of The service lifecycle and Keeping services running.
Oneshot services #
A Oneshot service is a run-to-completion task. peinit forks, installs the token, execs the binary, and waits for it to exit. It is the right type for setup work: creating directories, initialising a database, running a schema migration, applying a one-time fixup at boot.
For a Oneshot, readiness is exit, not a signal. The Readiness field is ignored entirely — READY=1 is meaningless for a process whose whole job is to finish. Success and failure are decided by the exit code:
- Exit 0 (or any code listed in
SuccessExitCodes) → the service succeeded. - Any other exit, or death by signal → the service failed, and the failure policy applies.
SuccessExitCodes is for binaries that signal a meaningful outcome with a non-zero code — for example, a tool that exits 2 to mean "nothing to do." Each entry is a decimal 0–255; code 0 is always success and need not be listed.
Completed, and RemainAfterExit #
A successful Oneshot transitions to Completed, and what happens next depends on RemainAfterExit:
RemainAfterExit | After a successful exit |
|---|---|
| 0 (default) | The service passes through Completed — releasing its dependents — then transitions to Inactive. |
| 1 | The service stays in Completed. |
Both forms satisfy dependents; the only difference is what a later status query shows. Use RemainAfterExit=1 when "this task is done" is a state worth seeing — a boot-time migration you want to confirm ran, say — rather than a service that quietly returns to Inactive.
Oneshot timing and hooks #
Two details distinguish a Oneshot's start sequence from a Simple one:
StartTimeoutcovers the entire run. For a Simple service the timeout covers startup until readiness; for a Oneshot it covers everything from the first pre-hook to process exit. A long-running Oneshot must raiseStartTimeoutaccordingly (or sendEXTEND_TIMEOUT_USECas it works — see Keeping services running).ExecStartPostruns only on success. Post-hooks fire after the successful exit. If the Oneshot fails,ExecStartPostdoes not run.
Restarting a Oneshot #
A Oneshot is not restarted on success, regardless of RestartPolicy — even Always. A successful exit is the goal, not a failure to retry. RestartPolicy governs only the response to failure (a non-zero exit). To re-run a Oneshot on a schedule, give it a timer trigger; that is the intended mechanism.
Simple vs Oneshot at a glance #
| Simple | Oneshot | |
|---|---|---|
| Process lifetime | Long-running; is the service | Runs once, exits |
| Readiness | Notify (READY=1) or Alive | Successful exit — Readiness ignored |
| Satisfies dependents when | Active | Completed (with or without RemainAfterExit) |
| Successful end state | Active (until stopped) | Completed, then Inactive (unless RemainAfterExit=1) |
ExecStartPost fires | After readiness | After successful exit only |
StartTimeout covers | Startup until readiness | The whole execution |
| Watchdog & health checks | Supervised while Active | None — WatchdogTimeout/HealthCheck are inert |
| Restart on success | n/a | Never (use a timer to re-run) |
The sd_notify contract #
A Notify-readiness service signals readiness by sending READY=1 over the sd_notify protocol — a datagram to the socket named in the NOTIFY_SOCKET environment variable, which peinit sets for every service. READY=1 is only the most visible message; the same channel carries watchdog keepalives (WATCHDOG=1), graceful-stop notice (STOPPING=1), timeout extensions (EXTEND_TIMEOUT_USEC), status strings (STATUS=), and the fd store. These are covered where they belong — readiness here, the rest in Keeping services running and Controlling services.
Readiness is always per start generation. A READY=1 left over from a previous incarnation of the process is never valid for the current start — peinit ties every notify message to the specific process it is currently supervising, verified by pidfd.
Who is allowed to send these messages is governed by NotifyAccess, which currently has a single value: Main (0, the default). Under Main, only the tracked main PID — the process peinit forked and supervises — is authorised to send sd_notify readiness and status messages; a message arriving from any other PID (a child, a helper, an unrelated process) is ignored. So if a worker your service spawns sends READY=1, peinit will not act on it — the readiness signal must come from the main process itself.
No forking daemons #
peinit does not support services that double-fork to background themselves. The whole reason a traditional daemon double-forks — to detach from the launcher — is moot when the launcher tracks the process it spawned. peinit obtains a pidfd for every process at fork time, which is a kernel handle to that exact process, immune to PID-reuse races. There is no MAINPID= mechanism for a service to redirect supervision onto a different process; peinit supervises what it forked.
If a legacy binary insists on daemonising, the fix lives at the packaging layer — wrap it so it stays in the foreground (commonly a --no-daemon/--foreground flag) — not at the init layer.
Where to start #
To control when either type of service starts, read Triggers and timers.
To follow a service from definition to running process and read its states, read The service lifecycle.
To configure restart, health checks, and watchdogs for a Simple service, read Keeping services running.
Triggers and timers
Peios / Using Peios / Services & jobs
A trigger decides when a service starts on its own. A service's triggers are independent of its type: a boot-triggered Oneshot and a timer-triggered Simple service are both perfectly ordinary. Triggers live in the Triggers field as a list, each entry written type or type:argument.
| Trigger | Form | Meaning |
|---|---|---|
| Boot | boot | Start during the Phase 2 boot sequence, in dependency order. |
| Timer | timer:<schedule> | Start on a schedule. The schedule is a calendar expression. |
A service with no triggers is demand-only: it never starts itself, and is brought up only by an explicit control command or because another service depends on it. Many services are demand-only by design.
Waiting for the boot to settle #
boot:settled is boot with one difference: the service starts once the Phase 2 boot set has stopped moving — every service in it reached a state it will not leave on its own — rather than during it.
It exists for services that write to a terminal. peinit reports its progress to /dev/console, and a service holding that same terminal writes there too, so a prompt started mid-boot gets written over: login emits Username: with no newline and waits, peinit appends a service line, and the result is unreadable until you press Enter and login redraws.
The wait is capped (Machine\System\Boot\SettleTimeout, 5 seconds by default). A service stuck in Starting therefore costs a deferred service a short delay rather than preventing it — which matters most exactly when something is broken and a console prompt is what you need.
A boot:settled service is deliberately not part of the boot plan. It does not consume the parallel-start budget, is not counted towards boot success, and cannot block another service. Waiting for quiet is a preference; it must not change what the boot means.
You can list more than one trigger, including more than one of the same type. ["timer:*-*-* 02:00:00", "timer:*-*-* 14:00:00"] runs at 2 am and 2 pm. The model is built to grow — future trigger types (path, device, event) will slot into the same list without a schema change.
The Disabled flag #
Disabled=1 suppresses automatic activation. A disabled service will not be started by any trigger — boot, timer, or anything added later — but it can still be started by hand through the control interface. It is the switch for "configured, but not running on its own right now."
Two related controls are easy to confuse with it:
| To… | Use |
|---|---|
| Stop a service from auto-starting, but keep manual start | Disabled=1 |
| Stop a service from being started at all, even manually | Deny SERVICE_START in its ServiceSecurity descriptor |
| Stop a running service | The stop command |
Enabling and disabling are registry writes performed by admin tooling, not peinit commands. peinit picks up a Disabled change through a registry notification or on reload-config. A disabled service's definition is still loaded into peinit's model, so it is ready for an on-demand start the instant you ask.
Calendar expressions #
Timer schedules are written in systemd's OnCalendar format. peinit's grammar is normative and self-contained — what follows is the whole language. The general shape is:
DayOfWeek Year-Month-Day Hour:Minute:Second Timezone
Every field is optional and has a default, so most real schedules are short.
| Field | Form | If omitted |
|---|---|---|
| DayOfWeek | weekday name(s) | any day (*) |
| Date | Year-Month-Day | *-*-* (any date) |
| Time | Hour:Minute:Second | 00:00:00 |
| Second | the :Second of Time | :00 |
| Timezone | IANA zone name | system-local time |
So 02:00:00 is a complete expression — date defaults to every day, and it means "every day at 02:00." Hour:Minute with no seconds defaults the seconds to 00.
Component syntax #
Each numeric component — year, month, day, hour, minute, second — and the weekday accept the same operators:
| Operator | Example | Matches |
|---|---|---|
| Wildcard | * | any value |
| List | 1,15 | any listed value |
| Range | Mon..Fri, 8..17 | the inclusive range |
| Repetition | 0/15 | 0, then every multiple of the step — 0, 15, 30, 45 |
| Range + step | 8..17/2 | 8, 10, 12, … up to and including 17 |
Weekdays are English names, case-insensitive, abbreviated (Mon) or full (Monday), and take lists and ranges.
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. So *-*~01 is the last day of every month. Repetition combines with it — Mon *-05~07/1 is "the last Monday in May."
Named shortcuts #
These stand in for the expression shown:
| 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 |
Timezones and precision #
A timezone specifier follows the IANA database — Europe/London, US/Eastern, UTC. With none, the expression is interpreted in system-local time.
Precision is second-level. Unlike systemd, peinit does not accept sub-second fractions; a fractional second is a parse error. Service scheduling has no use for finer granularity.
Examples #
| Expression | Meaning |
|---|---|
*-*-* 02:00:00 | Every day at 2 am (system-local) |
Mon *-*-* 00:00:00 | Every Monday at midnight |
*-*-1,15 12:00:00 | 1st and 15th of each month at noon |
*-*~01 00:00:00 | Last day of each month at midnight |
Mon..Fri *-*-* 09:00:00 | Weekdays at 9 am |
*-*-* *:00/15:00 | Every 15 minutes |
*-*-* 02:00:00 Europe/London | Every day at 2 am London time |
What happens when a timer fires #
A timer firing does not blindly start the service — peinit considers the service's current state first, so a firing never collides with a run already in progress.
| Type | Current state | Action |
|---|---|---|
| Oneshot | Inactive / Completed / Failed | Start it (operation source Timer). |
| Oneshot | Active / Starting | Set a single pending flag — one catch-up run when the current one finishes. |
| Simple | Inactive / Failed | Start it. |
| Simple | Active / Starting | No-op; the firing is logged. |
The Oneshot "pending" flag is deliberately not a queue: many firings that land while a Oneshot is busy collapse into one catch-up run, taken when the current run ends. A service cannot stampede itself.
That single flag is shared across all of a Oneshot's timers, not one per timer. If a Oneshot has several timer triggers and two of them fire while it is busy, at most one run is still pending when the current one finishes. The timers are otherwise fully independent — each keeps its own next-firing time and its own last-run timestamp — but they can only ever queue up a single shared catch-up between them.
Each timer firing creates a normal start operation, so a timer-started service is observable exactly like an admin-started one — same GUIDs, same events.
Persistent timers and catch-up #
TimerPersistent (default 1) controls whether runs missed while the machine was off are caught up after a reboot.
peinit records the last-run timestamp of each timer in the registry. On boot, for each persistent timer it reads that timestamp, computes the next firing after it, and — if that time is already in the past — fires once, immediately, then resumes the normal schedule.
The catch-up is always a single run, never one-per-missed-occurrence. A daily timer that was off for five days fires once on the next boot, not five times. A non-persistent timer (TimerPersistent=0) ignores history entirely and simply computes its next future firing from now.
One subtlety worth knowing: the last-run timestamp is keyed by the schedule string. If you change a timer's schedule, the old timestamp is orphaned and the new schedule has no history, which causes exactly one spurious catch-up run. Schedule changes are rare and the cost is one extra run, so peinit accepts it rather than tracking trigger identity.
Jitter #
TimerJitter (default 0) adds a random delay of 0 to TimerJitter seconds to each firing, recomputed every time. A daily timer with TimerJitter=900 fires at a slightly different moment between 00:00 and 00:15 each day. Jitter only ever delays — a timer never fires before its scheduled time. It is the standard remedy for a fleet of machines all hammering the same backend at exactly midnight.
When the clock jumps #
Calendar timers are wall-clock schedules: *-*-* 02:00:00 means "02:00 on the wall," not "every 86,400 seconds." peinit arms them as absolute real-time timers that are cancelled and recomputed whenever the system clock is stepped, so the schedule stays anchored to the wall clock across corrections.
| Event | Behaviour |
|---|---|
| NTP step or manual clock set | Armed calendar timers are cancelled and recomputed against the new wall clock. A backward step pushes the next firing later; a forward step that crosses a missed time fires it once. |
| Suspend / resume | A scheduled time that elapsed while suspended fires once on resume — once, not once per missed occurrence. |
| A missed occurrence within one uptime | Fire once, then resume the normal schedule. peinit never replays every occurrence in the gap. |
| Daylight-saving transition (timezone-aware timer only) | A scheduled time in the skipped hour never fires; a scheduled time in the repeated hour fires once. See below. |
Daylight-saving is the case most likely to surprise you, and it only affects timers that name an IANA zone that observes DST (a bare 02:30:00 in system-local time follows whatever the system does). Say you schedule *-*-* 02:30:00 Europe/London:
- Spring forward — the clock jumps from 01:59 straight to 03:00, so 02:30 does not exist on that day. The timer does not fire; it simply resumes at the next valid
02:30. - Fall back — the clock reaches 02:30, then rewinds and reaches 02:30 a second time. The timer fires exactly once, on the first occurrence, not twice.
This is distinct from peinit's interval timers — the watchdog, health-check intervals, restart backoff, and the start/stop/reload phase timeouts. Those are genuine durations measured on the monotonic clock, so "wait 30 seconds" means 30 elapsed seconds and never lurches when the wall clock is set.
There is one boot-time case worth calling out on its own. When peinit reads a timer's stored last-run timestamp at boot and finds it is in the future relative to the current wall clock — meaning the clock has gone backwards since that run — it can't trust the timestamp, so it treats the timer as having an unknown last run and fires the persistent catch-up immediately. This only applies to the boot-time check; the runtime handling above is unaffected.
Where to start #
To see how a timer-started run appears as a job and an operation, read Jobs and operations.
To understand the states a timer-started service moves through, read The service lifecycle.
For the exact registry keys that store last-run timestamps, see the Registry key reference.
The service lifecycle
Peios / Using Peios / Services & jobs
A service managed by peinit is, at every instant, in exactly one state. The state is what a status query reports, what gates dependents, and what decides which commands are valid. Alongside the state, peinit records the cause of the most recent transition — the why behind the where. Reading a status is reading these two things together: "Failed, because RestartBudgetExhausted" tells a very different story from "Failed, because ValidationError."
This page is the reference for both. It is worth internalising before Controlling services and Troubleshooting, because both lean on it.
The states #
| State | Process? | Satisfies dependents? | What it means when you see it |
|---|---|---|---|
| Inactive | No | No | Not started, or stopped cleanly and not set to restart. The neutral resting state. |
| Starting | Maybe | No | Activation in progress — conditions, hooks, fork, or readiness wait. Dependents are blocked. |
| Active | Yes | Yes | Running and ready. The normal state of a healthy Simple service. |
| Reloading | Yes | Yes | Re-reading configuration. Still counts as running. |
| Stopping | Yes (briefly) | No | SIGTERM sent; waiting for exit or SIGKILL escalation. |
| Completed | No | Yes | Oneshot finished successfully. Stays here only with RemainAfterExit=1. |
| Backoff | No | No | A restart is pending; the service is waiting out its backoff delay before the next attempt. |
| Failed | No | No | Exited abnormally and restart policy is exhausted or not configured. |
| Abandoned | Yes (unkillable) | No | SIGKILL was sent but the process survived in uninterruptible sleep (D-state). peinit has given up supervising it; its cgroup is leaked. |
| Skipped | No | Yes | A start-time condition was not met. The service does not apply here. |
Three of these — Active, Completed, Skipped — satisfy dependents. Everything else blocks them. That single column is the rule the whole dependency system turns on: a service waiting on a Requires target does not move until that target reaches one of those three.
The common path #
Most of a service's life is a small loop. The diagram below shows the states a healthy Simple service moves through, plus the two ways it leaves Active.
stateDiagram-v2
[*] --> Inactive
Inactive --> Starting: start / trigger / dependency
Starting --> Active: ready (READY=1 or alive)
Active --> Stopping: stop / shutdown / conflict
Stopping --> Inactive: exited (clean stop / shutdown)
Stopping --> Failed: exited (conflict / BindsTo eviction)
Active --> Backoff: crash + restart allowed
Backoff --> Starting: backoff delay elapsed
Active --> Failed: crash + no restart
Starting --> Failed: timeout / hook / setup failure
Failed --> Starting: explicit start
A few things this picture makes concrete:
- An automatic restart always routes through Backoff, never through Failed.
Backoff → Startingis the retry;Failedis reached only when restarts are exhausted or disabled. This is whyOnFailurefires once at the end, not on every retry. Startingcan fail before any process exists — a parent-side setup error, a failed pre-hook, a condition or assert. The cause records which.Failed → Startingis how a manualstart(or a recovery path) revives a dead service; automatic restarts never originate fromFailed.Stoppinghas two exits. A clean stop or a shutdown lands inInactive. But a service stopped because it lost aConflictsrace (ConflictEviction) or because itsBindsTotarget went away (BindsToPropagation) comes to rest inFailed, carrying that cause — so an evicted or bound-out service shows as Failed instatusand needs areset(or, for a bound service, its target returning) before it starts again. It was not shut down on purpose, so peinit does not treat it as cleanly Inactive.
Oneshot services follow a parallel path through Completed instead of Active: Starting → Completed, and then either staying there (RemainAfterExit=1) or passing through to Inactive. See Simple and Oneshot services.
Transition causes #
Every transition carries a cause. peinit keeps the cause of the most recent transition on the service, and emits all of them to eventd. The full taxonomy, grouped by what kind of thing happened:
Why a service started
| Cause | Meaning |
|---|---|
ExplicitStart | An administrator or a trigger started it. |
DependencyStart | Started to satisfy another service's dependency. |
RestartPolicy | An automatic restart, after a backoff delay. |
BindsToRecovery | A bound target returned to Active; the dependent is auto-restarted. Does not consume the restart budget. |
Why a service stopped
| Cause | Meaning |
|---|---|
ExplicitStop | An administrator requested stop. |
ConflictEviction | A conflicting service started and won. |
BindsToPropagation | A bound target stopped, so this one stops too. |
ShutdownWave | The system is shutting down. |
Why a service failed or restarted — the diagnostic causes
| Cause | Meaning |
|---|---|
ProcessCrash | The main process exited non-zero or died on a signal. |
CleanExit | A Simple process exited successfully, and policy is not Always — not a crash; goes straight to Inactive. |
CleanExitRestart | A Simple process exited successfully but RestartPolicy=Always, so it is restarted. Logged clearly as not a crash. |
ReadinessTimeout | StartTimeout expired before the service became ready. |
WatchdogTimeout | A WATCHDOG=1 keepalive did not arrive in time. |
HealthCheckFailure | HealthCheckRetries consecutive health checks failed. |
PreHookFailure | An ExecStartPre hook exited non-zero. |
ParentSetupFailure | peinit could not even fork — resource exhaustion, cgroup error. No child was created. |
PreExecFailure | Setup after fork but before exec failed (token install, rlimits, environment). |
RestartBudgetExhausted | RestartMaxRetries reached within the window; no more retries. |
Why a service was rejected — definition and graph problems
| Cause | Meaning |
|---|---|
ValidationError | The definition failed validation (bad field, unresolvable conflict, illegal health-check timing…). |
CycleDetected | The service is part of a dependency cycle. |
DependencyFailure | A Requires dependency entered Failed or does not exist. |
AssertionError | A start-time Assert failed. |
ConditionSkipped | A start-time Condition was not met → Skipped (this is not a failure). |
ProcessUnkillable | The process survived SIGKILL (D-state) → Abandoned. |
ExplicitReset | An administrator cleared Failed/Abandoned/Skipped without starting. |
The grouping matters for what peinit does next: the diagnostic causes are mostly restart-eligible (peinit consults restart policy), whereas the definition/graph causes are never restarted — retrying a broken definition cannot help. The distinction is spelled out in Keeping services running.
Readiness gating during boot #
The dependent-satisfaction rule is what makes boot orderly. A service's dependents do not start until it satisfies them:
- A Simple service satisfies dependents when it signals
READY=1(Notify) or when its process exists (Alive). - A Oneshot service satisfies dependents when it exits successfully and reaches Completed.
- A Skipped service satisfies dependents immediately — it succeeded by not needing to run.
If a service does not reach readiness within its StartTimeout, its dependents diverge by relationship: Requires dependents transition to Failed (DependencyFailure); Wants dependents start anyway. That difference is the whole point of the two relationship types — see Dependencies and ordering.
The Abandoned state #
Abandoned is the one state that reflects a kernel-level problem rather than a service-level one. peinit reaches it when it has sent SIGKILL to a service's process group but the processes are still there after a grace period — the post-kill timeout, 5 seconds by default. If the service's cgroup has not emptied within it, the processes are wedged in uninterruptible kernel sleep (D-state), typically behind a hung mount or a broken storage controller, and the service is marked Abandoned (its cgroup leaked).
peinit cannot kill a D-state process; nothing in userspace can. So it stops trying: it marks the service Abandoned, leaks the cgroup (it cannot be removed while populated), and moves on rather than hanging. The leak is never silent — it shows up in the service's warnings and, on a later start, peinit creates a fresh "generational" cgroup so the new instance is unaffected by the stuck old one.
An Abandoned service is cleared with reset, which re-checks the cgroup: if it finally emptied, peinit cleans up; if it is still populated, peinit leaves it leaked and warns you. An Abandoned service is a sign of an underlying I/O fault that needs investigating, not something to paper over with a restart.
Invariants #
A handful of rules hold without exception:
- A service is in exactly one state at any moment.
- Only the transitions peinit defines are valid — there are no others.
- Only peinit changes a service's runtime state. No external process can reach in and set it.
- A service's securable identity (its ServiceSecurity descriptor — who can manage it) is independent of its process identity (its token — what it can access).
- Readiness is per start generation — a
READY=1from a previous incarnation is never honoured for the current start.
Where to start #
To understand what happens after a service crashes — restart policy, backoff, health checks, watchdogs — read Keeping services running.
To understand how dependents block on and are released by these states, read Dependencies and ordering.
To see which commands are valid in which state, read the command × state matrix in Controlling services.
Keeping services running
Peios / Using Peios / Services & jobs
Once a service is Active, peinit's job becomes keeping it that way — or deciding, deliberately, when to stop trying. That decision is governed by a small set of policies: a restart policy with a throttling budget, optional active health checks, an optional watchdog, and an error-control level that decides how serious a final failure is. This page covers all of them.
Restart policy #
When a service fails in a way that might be transient, peinit consults RestartPolicy:
| Policy | Value | Behaviour |
|---|---|---|
| Never | 0 | Never restart. The service goes straight to Failed. |
| OnFailure (default) | 1 | Restart on a crash or runtime failure. An exit code in SuccessExitCodes is not a failure and is not restarted. |
| Always | 2 | Restart on any failure, and — for a Simple service — restart even a successful clean exit (see clean exits). |
Not every failure is restart-eligible. peinit splits causes into three groups:
- Restart-eligible — peinit consults the policy and budget:
ProcessCrash,WatchdogTimeout,HealthCheckFailure,ReadinessTimeout,PreHookFailure,PreExecFailure,ParentSetupFailure. (The startup failures are eligible on purpose — a pre-hook that failed on a momentarily-missing mount deserves another try.) - Never restarted — retrying cannot help: an explicit stop or reset, shutdown, conflict eviction, a broken definition (
ValidationError,CycleDetected,DependencyFailure,AssertionError), an already-exhausted budget, or an unkillable process. - Budget-exempt —
BindsToRecovery, where a bound service came back and its dependents are revived. The dependent did not fail on its own, so this restart does not count against its budget.
The budget and backoff #
Restarting an instantly-crashing service in a tight loop is worse than useless, so every restart is throttled by two mechanisms working together.
Exponential backoff. Before each restart the service sits in the Backoff state for a delay that doubles on each consecutive failure, starting from RestartDelay (default 1 s) and capped at 60 s. So a crash-looping service backs off 1 s, 2 s, 4 s, 8 s, … up to a minute between attempts.
The budget. A consecutive-failure counter tracks how many times the service has failed in a row. Once it reaches RestartMaxRetries (default 5), the next failure is not restarted: the service transitions to Failed with cause RestartBudgetExhausted.
The reset. The counter resets to zero once the service stays Active for RestartWindow seconds (default 120) without failing. This is the crucial detail: the budget is not "N restarts ever" — it is "N restarts faster than the service can sustain RestartWindow of health." A service that crashes once a day and recovers cleanly each time never exhausts its budget, because every crash starts from a counter of zero. Only failures recurring faster than the service can stay healthy accumulate.
flowchart LR
A["Active"] -->|crash| B{"budget left?"}
B -->|yes| C["Backoff<br/>delay doubles"]
C -->|delay elapsed| D["Starting"]
D -->|ready, stays Active<br/>RestartWindow| A
B -->|no| E["Failed<br/>RestartBudgetExhausted"]
Clean exits #
For a Simple service, exiting with code 0 (or a SuccessExitCodes match) is success, not a crash — a daemon chose to quit. What peinit does next depends on the policy:
- Under
NeverorOnFailure, the cause isCleanExitand the service goes straight to Inactive, with no restart-policy consultation at all. - Under
Always, the cause isCleanExitRestartand the service is restarted — but through the same backoff and budget as a failure, so a daemon that exits cleanly in a tight loop cannot bypass throttling. Logs and status make clear it exited successfully and was restarted only because the policy is Always.
(A Oneshot clean exit is never restart-eligible regardless of policy — see service types.)
Health checks #
A live process is not always a working one — it can lose its database connection, wedge in a bad state, or return errors while still "up." An active health check closes that gap by running a command periodically and treating its exit code as a verdict: 0 is healthy, non-zero is a failure.
Health checks apply to Simple services only. peinit starts a service's health-check timer once the service becomes Active; a Oneshot has no long-running process to probe, so it never has one. HealthCheck and its companion fields are inert on a Oneshot — no timer is started — so don't set them there expecting supervision.
| Field | Default | Controls |
|---|---|---|
HealthCheck | — | The command to run (runs under the service's own token). |
HealthCheckInterval | 30 | Seconds between runs. |
HealthCheckTimeout | 5 | Seconds before a check is killed and counted as failed. |
HealthCheckRetries | 3 | Consecutive failures before the service is declared unhealthy. |
HealthCheckRetries consecutive failures mark the service unhealthy and restart it through the normal restart policy — backoff and budget included. A single success resets the failure count. If a check is still running when the next interval fires, the new one is skipped rather than stacked.
The health field in a status response reflects this: healthy, unhealthy (failing but not yet failed out), unknown (configured, no result yet), or null (no health check).
The flap guardrail. Health-check throttling only works if a failure cycle is shorter than the restart window — otherwise the counter resets between failures and the service restarts forever. peinit enforces this at validation time as a hard rule:
HealthCheckRetries × HealthCheckInterval < RestartWindow
A definition that violates it is a validation error, not a warning — the service is marked Failed with ValidationError rather than allowed to restart indefinitely.
A health check stuck in D-state is handled like any other stuck helper: its sub-cgroup is leaked and surfaced as a warning, but — unlike a stuck main process — it does not push the service to Abandoned. A health check holds no service resources; it is only a probe.
The watchdog #
The watchdog inverts the health check: instead of peinit asking the service if it is alive, the service must periodically tell peinit. Set WatchdogTimeout (seconds; 0 disables) and the service must send WATCHDOG=1 via sd_notify at least that often. Miss the deadline and peinit treats it as a failure (cause WatchdogTimeout) and applies the restart policy.
Like health checks, the watchdog is a Simple-only supervision mechanism. peinit arms the watchdog timer only once a Simple service becomes Active; on a Oneshot WatchdogTimeout is inert — no timer is started, and setting it changes nothing.
A service can adjust its own watchdog at runtime by sending WATCHDOG_USEC=<microseconds>: a positive value re-arms the timer with the new interval immediately; 0 disables the watchdog. This is for services whose phases have different latencies — a database might run a tight 5 s watchdog normally but widen it to 60 s during a compaction pass, because it knows its own phases better than the schema author did. The runtime value does not persist: a restart reverts to the schema's WatchdogTimeout.
Timeout extension #
A service that does genuinely slow work during a transition can ask for more time by sending EXTEND_TIMEOUT_USEC=<microseconds> while it is Starting, Stopping, or Reloading. Each message replaces the current phase deadline (it is not additive); a service that keeps sending them keeps proving it is making progress, and if it stops, the last deadline fires and peinit escalates normally.
The extension is capped to prevent a buggy service from extending forever:
| Phase | Base timeout | Maximum deadline |
|---|---|---|
| Starting | StartTimeout | StartTimeout × 4 |
| Stopping | StopTimeout | StopTimeout × 4 |
| Reloading | StartTimeout | StartTimeout × 4 |
During shutdown a second, global cap also applies — the deadline can never exceed the remaining time in the global shutdown timeout, and the stricter of the two caps wins. A message received outside a transition (the service is Active, Failed, …) is ignored — there is no timeout to extend.
OnFailure #
When a service enters Failed, an OnFailure field names another service to start in response — a fallback for graceful degradation (the main web UI failed; bring up a minimal emergency endpoint). It is not a monitoring or alerting hook; that is eventd's job.
OnFailure fires for runtime failures — ProcessCrash, WatchdogTimeout, HealthCheckFailure, the startup-failure causes, and non-Critical budget exhaustion. It deliberately does not fire for:
ShutdownWave— nothing new starts during shutdown.ValidationError,CycleDetected,DependencyFailure,AssertionError— these are definition or graph breakage, not a running service degrading. The fallback would likely sit in the same broken graph anyway.
Because a fallback can itself fail and carry its own OnFailure, peinit bounds the chain: it tracks which services it has already started for one originating failure, refuses to start one twice, and never follows the chain beyond a depth of 16. When the guard trips, it logs the loop and stops.
ErrorControl: when failure is not an option #
ErrorControl decides how serious an irrecoverable failure is:
| Level | Value | On irrecoverable failure |
|---|---|---|
| Normal (default) | 0 | The service stays in Failed. The rest of the system carries on. |
| Critical | 1 | peinit syncs filesystems and reboots immediately. |
A Critical service is one the system genuinely cannot run without — the platform daemons registryd, authd, lpsd, eventd are all Critical. "Irrecoverable" means the restart budget was exhausted: peinit tried, backed off, retried up to RestartMaxRetries, and the service still would not stay healthy. At that point the fastest path to a known-good system is a reboot.
A few consequences worth holding onto:
- Critical failure outranks
OnFailure. When a Critical service exhausts its budget, peinit reboots; it does not start theOnFailurehandler. ErrorControl=CriticalimpliesSafeMode. A Critical service is always eligible to start in Safe mode.- Critical services are OOM-protected. peinit sets their
oom_score_adjto-1000, so the kernel will not pick them as out-of-memory victims. - A runtime Critical crash does not enter Safe mode — it follows the reboot path. Safe mode is only for boot-time configuration errors (a cycle or conflict involving a Critical service). The escalation between reboots, the boot-attempt counter, and Recovery mode are covered in Boot and boot modes.
Where to start #
To see exactly which states these policies move a service through, read The service lifecycle.
To understand how a failed service propagates to the ones that depend on it, read Dependencies and ordering.
When a service will not stay up, work backward from the symptom in Troubleshooting peinit.
Dependencies and ordering
Peios / Using Peios / Services & jobs
Services rarely stand alone. A web app needs its database; a network daemon needs the loopback interface up; two implementations of the same role must never run at once. peinit expresses all of this with four relationship fields — Requires, Wants, BindsTo, and Conflicts — that together decide the order services start in, what happens when one stops, and how a failure spreads to its neighbours.
The relationships are declared on the dependent (the service that needs something), naming the target (the service it needs) by name. Conflicts is the exception — it is symmetric, and one side declaring it is enough.
The four relationships #
Requires — a hard dependency #
If A Requires B:
- Start. B must reach a satisfying state before A starts. If B is not running, peinit starts it first (cause
DependencyStart). If B fails, A is marked Failed withDependencyFailureand never even attempts to start. - Stop. Stopping B does not stop A. Requires is a start-ordering constraint, not a runtime leash — A keeps running.
- Runtime failure. If B crashes while A is already Active, A is unaffected. B's own restart policy handles B's recovery.
Requires is the workhorse: "I need this to have started, and if it can't, neither can I."
Wants — a soft dependency #
If A Wants B:
- Start. peinit starts B before A if B exists and is not Disabled — but if B fails to start, or does not exist, A starts anyway.
- Stop / failure. No effect either way. A and B are runtime-independent.
Wants is best-effort ordering: "start this first if you can, but I work without it."
BindsTo — a runtime coupling #
If A BindsTo B:
- Start. Identical to Requires — B must satisfy before A starts.
- Stop. If B stops for any reason — explicit stop, conflict, crash, shutdown — A is stopped too, transitioning to Stopping with cause
BindsToPropagation. When A finishes stopping it comes to rest in Failed (carryingBindsToPropagation), not Inactive — so astatusquery shows the bound service as Failed, making it clear it was taken down by its target rather than shut down cleanly. - Recovery. When B returns to Active, peinit automatically restarts A from that Failed state (cause
BindsToRecovery). This is reactive — peinit watches B's transitions — and it is budget-exempt: A did not fail on its own, so the restart does not count against its budget. If B never comes back, A stays Failed until youreset(or start) it.
BindsTo is Requires plus a runtime leash: "I need this to start, and I should not outlive it." It is the right choice for a sidecar that is meaningless without its principal. BindsTo implies Requires; listing both for the same target is harmless, and BindsTo semantics win.
Conflicts — mutual exclusion #
If A Conflicts with B:
- Start. Starting A while B is Active creates a stop operation for B (source
ConflictResolution), evicting it (causeConflictEviction) before A starts — and vice versa. If the loser will not stop within itsStopTimeout, SIGKILL escalation applies. The evicted loser does not land in Inactive: it comes to rest in Failed (carryingConflictEviction), so it shows as Failed instatusand needs aresetbefore it will start again. That is deliberate — an evicted service is not a clean stop, and leaving it Failed stops it from quietly restarting straight back into the conflict. - Symmetry. Conflicts is two-way. If A declares
Conflicts=["B"], starting either stops the other; B need not declare it back.
Conflicts is for true mutual exclusion — two services binding the same port, or two implementations of one role where exactly one must run — not for ordinary resource contention.
At a glance #
| Start order | Target failure stops dependent? | Target stop stops dependent? | |
|---|---|---|---|
| Requires | target first; dependent fails if target fails | At start time only | No |
| Wants | target first if present; dependent starts regardless | No | No |
| BindsTo | target first; dependent fails if target fails | Yes (and auto-recovers) | Yes (and auto-recovers) |
| Conflicts | starting one evicts the other | n/a | n/a |
Graph validation #
Before peinit starts anything, it builds the dependency graph and validates it. Validation runs once per graph build — at boot for the whole boot graph, and per request for an on-demand start's transitive closure. It is not incremental.
Cycles. peinit topologically sorts the graph; if the sort fails, there is a cycle. Every service in the cycle is marked Failed with CycleDetected, and peinit logs the full path (dependency cycle: A → B → C → A) so you can find and break it. A service may not depend on itself — a self-reference is rejected as a cycle. If any service in the cycle is ErrorControl=Critical, peinit downgrades to Safe mode rather than rebooting, because a reboot would just hit the same cycle.
Missing targets. What a missing target means depends on the relationship:
| Relationship | Missing or disabled target |
|---|---|
Requires | Dependent marked Failed (DependencyFailure). |
BindsTo | Treated as a missing Requires — Failed (DependencyFailure). |
Wants | Silently ignored — the entry is dropped. |
Conflicts | Silently ignored — nothing to conflict with. |
Unresolvable conflicts. If two boot-triggered services conflict with each other, there is no way to honour both, so both are marked Failed with ValidationError. As with cycles, if either is Critical, Safe mode applies.
Warnings. Some conditions are logged but do not block boot — most notably a Readiness=Alive service that others Requires (see service types). Warnings appear in the logs and do not change state.
Validation errors. Some conditions do fail the service — for example a health-check configuration that violates the flap constraint. These mark the service Failed with ValidationError.
When more than one finding applies to the same service, peinit records a single primary cause by precedence — CycleDetected > ValidationError > DependencyFailure — but it logs all findings, so the primary cause never hides the others.
Parallel start #
After validation, peinit starts services whose dependencies are all satisfied, and it does so in parallel up to a configurable limit:
| Registry key | Default | Meaning |
|---|---|---|
Machine\System\Boot\MaxParallelStarts | 10 | Maximum services starting concurrently. |
The scheduler is simple: every service with no unsatisfied dependencies is eligible; peinit starts up to MaxParallelStarts of them; as each one reaches a satisfying state its dependents become eligible and join the queue. The result is that independent subtrees of the graph come up at the same time, while ordering constraints are still honoured exactly.
Failure propagation #
When a service enters Failed during graph execution, the failure spreads along Requires and BindsTo edges — and only those:
- Every service that
Requiresthe failed one transitions to Failed withDependencyFailure. - Every service that merely
Wantsit is unaffected and starts normally. - Propagation is transitive: if A requires B and B requires C, and C fails, then B fails, then A fails — each with
DependencyFailure.
This is the payoff of the Requires/Wants distinction. A hard dependency failing takes its dependents down with it; a soft one failing is shrugged off. Choosing the right relationship is choosing how far a failure is allowed to travel.
On-demand starts #
When you start a service explicitly rather than at boot, peinit does the same graph work on a smaller scope — the requested service's transitive closure:
- Collect all transitive
RequiresandBindsTodependencies (and best-effortWants). - Validate that sub-graph (cycles, missing targets).
- Resolve
Conflicts— stop anything that conflicts. - Start the sub-graph with the same parallel scheduler.
Anything already in a satisfying state (Active, Completed, Skipped) is left alone — its dependency is already met, so there is no needless restart. Dependencies pulled in this way start with cause DependencyStart. If two on-demand starts need the same dependency at once, their start operations merge rather than racing.
Shutdown reverses the graph #
peinit does not need a separate stop-ordering configuration. Shutdown simply reverses the dependency graph: services with no dependents stop first, and services that others depend on stop last. A service is never stopped until everything that Requires or BindsTo it has already stopped.
The very last services to stop are the TCB daemons everything rests on — eventd, then authd, then lpsd, then registryd — with registryd stopped dead last, mirroring its position as the first service ever started. The full shutdown sequence is in Shutdown.
Where to start #
To see the states services move through as they start and stop in order, read The service lifecycle.
To understand how a target's failure interacts with the dependent's own restart policy, read Keeping services running.
To follow how dependency-driven starts appear as operations, read Jobs and operations.
Service identity and privileges
Peios / Using Peios / Services & jobs
Every service process runs under a KACS token — the kernel object that carries identity into every system call and is the input to every access decision. peinit's responsibility is to get the right token for a service and install it on the child process before the binary execs, so the service runs as the intended principal from its very first instruction.
One rule frames everything else: peinit never shares its own SYSTEM token with a service. Even a service configured Identity=SYSTEM gets a separately materialised token of its own — never peinit's. This keeps every service's identity independent and auditable, and it is one of peinit's security invariants.
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. |
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, authd, lpsd, and eventd all run as SYSTEM and are all minted this way during boot, before authd is available to mint anything.
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.
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 is also what keeps the platform services — all running as SYSTEM — distinguishable to AccessCheck. See Well-known principals for the SID landscape this fits into.
Privilege restriction #
A token comes from its source (authd or the SYSTEM mint) with a default set of privileges. RequiredPrivileges lets a definition trim that set down to only what the service needs:
- peinit reads the
RequiredPrivilegesallow-list and removes every privilege not on it from the token before exec. - Restriction is purely subtractive. peinit removes privileges; it never adds them. A service cannot acquire a privilege its token's source did not grant.
- If
RequiredPrivilegesis absent, the token's default privilege set is used unchanged.
This is least-privilege made concrete: eudev, for instance, runs as SYSTEM (it needs device access during early boot) but with its privilege set stripped to the minimum it actually uses, so a compromise of eudev does not hand an attacker the full SYSTEM privilege set. Removal is permanent for the life of that token — it is not a disable that the service can re-enable. See Privileges for what the individual privileges grant.
Which identity runs what #
A service is not just its main process — it has hooks, health checks, and a reload command, and each runs under a defined identity:
| Context | Runs as |
|---|---|
| Main process | The service's Identity. |
ExecStartPre / ExecStartPost | HookIdentity if set, otherwise the service's Identity. |
| Health checks | The service's Identity, always. |
ExecReload (command form) | The service's Identity, always — HookIdentity does not apply. |
| Ad-hoc jobs | The token captured by JFS — the submitter's (possibly impersonated) identity. |
Token materialisation for hooks follows the same rules: a HookIdentity of SYSTEM is minted; any other principal is requested from authd, at the point the hook runs.
HookIdentity exists so hooks can run with different — usually higher — privileges than the service itself: creating directories in privileged locations, or running a database migration as an admin identity, before the long-running daemon drops to a lesser identity.
Security invariants #
The identity model rests on a few rules peinit never breaks:
- peinit never shares its SYSTEM token. Even
Identity=SYSTEMservices get a separately minted token. - peinit never drops its own SYSTEM identity. PID 1 runs as SYSTEM for the life of the system — its identity is axiomatic, granted by the kernel at boot.
- Privileges are subtractive only.
RequiredPrivilegesremoves; it can never add. - Identity is deterministic. Every service runs as a known principal.
SYSTEMmust be declared explicitly; an emptyIdentityis the minimalLocalService, never SYSTEM.
Where to start #
Identity decides what a service can do; the ServiceSecurity descriptor decides who can manage it — two independent concerns. Read Who can manage a service for the other half.
To see how the token is installed alongside the rest of the process setup, read The execution environment.
For the identity primitives themselves — tokens, SIDs, privileges — start at Tokens.
The execution environment
Peios / Using Peios / Services & jobs
When peinit starts a service, the process it hands you is built deliberately — a clean context with a known identity, a known environment, and only the resources peinit chose to give it. This page covers everything about that context except the token (its own page): the cgroup the process lives in, its standard streams, the environment it sees, the limits on it, and the hooks and checks that run around the start.
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.
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.
Standard streams #
peinit wires a service's standard streams before exec:
- stdin is redirected to
/dev/null. peinit never gives a service an interactive input channel; a service that needs input must obtain it explicitly (a socket, a stored fd). - stdout and stderr are redirected to pipes peinit holds, so it can capture and forward every line. This is the subject of Service output and logging.
Attaching a terminal #
A service that sets TTYPath to an absolute terminal path — /dev/console, /dev/tty1, a serial line — gets that terminal on all three standard streams instead of the wiring above, and becomes a session leader owning it as its controlling terminal. That last part is what makes job control work: Ctrl-C interrupts, fg/bg behave, and a hangup reaches the process group. It is what a boot shell or a login prompt needs.
It carries a path rather than a flag because which terminal is a per-service question: a maintenance shell on tty1 while the kernel console is a serial line is an ordinary thing to want.
The environment #
peinit constructs each service's environment in layers, lowest precedence first — it does not pass through its own near-empty startup environment.
- Compiled-in base. A fixed floor peinit always provides — currently just
PATH:/bin. - Global
EnvVars. Each value underMachine\System\Init\EnvVars\becomes a variable (value name → variable name).EnvVars\PATHoverrides the basePATH; other names add. Every service gets this layer except registryd, which serves the key itself — see the warning below for why that exemption exists. - Per-service
Environment. The definition's ownKEY=VALUEentries, which override layers 1–2. - Protocol variables.
NOTIFY_SOCKET(always) andLISTEN_FDS/LISTEN_FDNAMES(only when stored fds are injected). These have the highest precedence and cannot be overridden — a service that clobberedNOTIFY_SOCKETwould break sd_notify.
A change to EnvVars\ takes effect on a service's next start, like the per-service Environment field — it is not pushed into running services.
peinit deliberately does not set HOME, USER, LOGNAME, SHELL, or TERM. Peios identity is a token (a SID), not a passwd entry, so there is no canonical home directory or login shell to fill in. A service that needs any of these supplies it through EnvVars\ or its own Environment.
Working directory and limits #
| Field | Effect |
|---|---|
WorkingDirectory | The process's working directory. Must be a non-empty absolute path; defaults to /. |
LimitNOFILE | RLIMIT_NOFILE — the maximum number of open file descriptors. |
LimitCORE | RLIMIT_CORE — the maximum core-dump size, in bytes. |
peinit also sets oom_score_adj: -1000 (OOM-immune) for ErrorControl=Critical services, and the default 0 for everyone else — so the kernel never picks a Critical platform daemon as an out-of-memory victim.
Runtime directories #
RuntimeDirectories gives a service private scratch space under /run without an ExecStartPre that has to mkdir it by hand. Each name you list becomes a directory created directly under /run — RuntimeDirectories=myapp yields /run/myapp — set up immediately before the main process starts. Each one gets a security descriptor granting full access to SYSTEM, Administrators, and the service's own SID, so the service can write to its directory while other principals cannot. If a directory can't be created, or its descriptor can't be applied, the start fails with ParentSetupFailure.
peinit does not remove these directories when the service stops. /run is a boot-scoped tmpfs, so they simply disappear at the next boot rather than being torn down on each stop — a restarting service finds its runtime directory (though not necessarily its contents) already in place.
For the exact naming rules on each entry and the field's type and default, see the Registry key reference.
Pre-exec and post-exec hooks #
Hooks let a service do work around its main process without baking it into the binary.
ExecStartPreruns before the main binary. Each command runs in sequence, in thehooks/sub-cgroup; any hook exiting non-zero aborts the start (the whole cgroup is killed and the service Fails withPreHookFailure). Use it for prerequisites that must hold before the service starts — creating a runtime directory, waiting on a precondition.ExecStartPostruns after readiness (Simple) or after a successful exit (Oneshot). A post-hook failure is logged but does not fail the service — by the time it runs, the service is already up. (A Oneshot that fails never runs its post-hooks.)
Hooks run under HookIdentity if set, otherwise the service's own identity. Their output is captured and tagged like the main process's, labelled with the hook (e.g. jellyfin/ExecStartPre[0]).
Command string parsing #
The command fields — ExecStartPre, ExecStartPost, ExecReload, HealthCheck — are parsed into an argument list with simple, predictable rules. peinit never invokes a shell.
- Whitespace splits the command into arguments; double quotes group text containing spaces into one argument and are not retained (
--name="hello world"→ one argument--name=hello world). - There is no shell expansion, variable substitution, or globbing. Backslash is literal; a single quote is an ordinary character.
- The first argument is the executable and must be an absolute path beginning with
/. Relative names and PATH search are validation errors. - An empty or whitespace-only command, or an unclosed quote, is a validation error.
If a command genuinely needs shell features, wrap it in a script and point the field at the script.
ExecReload can also be a signal, written signal:<NAME> — for example signal:SIGHUP. The name must be an exact, canonical Linux signal name (uppercase, no aliases, no numbers); SIGKILL and SIGSTOP are rejected because they cannot be handled as a reload. With no ExecReload at all, reload sends SIGHUP. The reload lifecycle is covered in Controlling services.
Conditions and asserts #
Before peinit runs any hook or the main process, it evaluates conditions and asserts — start-time checks in the same type:argument form. The difference is what failure means:
- A failed Condition skips the service — it transitions to Skipped, which satisfies its dependents (it succeeded by not needing to run). Conditions express "only run here if it makes sense."
- A failed Assert fails the service — it transitions to Failed with
AssertionError. Asserts express "this was expected to run, and a precondition is missing."
Conditions are checked first; only if all pass are asserts checked. All entries are AND-ed. The check types:
| Type | Form | Passes when |
|---|---|---|
path | path:<path> | the path exists (any type) |
file | file:<path> | a regular file exists |
directory | directory:<path> | a directory exists |
registry | registry:<key> | the registry key exists |
Two constraints follow from peinit being single-threaded PID 1, which must never block:
registry:checks are cache-only. Aregistry:check may only name a key peinit already caches (underMachine\System\Services\orMachine\System\Init\); it is evaluated against the in-memory model, never a live read. Naming any other key is a validation error.- Filesystem checks run off the main loop.
path:/file:/directory:checks are performed by a short-lived forked helper, not astat()on the event loop, becausestat()can wedge on a hung mount.
That timeout is the PreStartCheckTimeout field, which defaults to 5 seconds. A filesystem check that does not finish within it — say a stat() wedged on a hung mount — is treated as not satisfied, fail-safe: peinit kills the helper and treats the check as unmet, so a Condition skips the service and an Assert fails it, rather than letting the event loop hang.
The fd store #
The fd store lets a service preserve file descriptors across restarts — most usefully a listening socket, so a stateful daemon can restart without dropping connections. It is opt-in: FdStoreMax (default 0) sets the maximum number of fds peinit will hold; 0 disables it.
The mechanics ride on sd_notify:
- A service stores an fd by sending
FDSTORE=1with the fd attached (and optionallyFDNAME=<name>; the default name isstored). peinit holds it. - It removes one with
FDSTOREREMOVE=1andFDNAME=<name>. - On the next start, peinit injects the stored fds starting at fd 3, sets
LISTEN_FDSto the count andLISTEN_FDNAMESto the colon-separated names, then clears the store. (This is the sameLISTEN_FDSconvention systemd uses, so software with existing fd-passing support works unmodified.)
The store survives an automatic restart (crash → restart policy → new start) — that is the whole point. It is cleared on an explicit stop or shutdown (the service is not coming back), and when a removed definition is finally discarded.
Where to start #
For the identity half of the launch — how the token is chosen and installed — read Service identity and privileges.
For what happens to the stdout/stderr pipes peinit holds, read Service output and logging.
For how conditions, asserts, and hooks fit into the start sequence and its states, read The service lifecycle.
Controlling services
Peios / Using Peios / Services & jobs
peiosctl is the command-line tool for driving peinit at runtime — starting and stopping services, querying their state, reloading configuration, and shutting the system down.
peiosctl <command> [service] [flags]
$ peiosctl status jellyfin # current state of one service
$ peiosctl start jellyfin # start it, wait until Active or Failed
$ peiosctl list # every service you can query
$ peiosctl shutdown reboot # graceful reboot
Underneath, peiosctl is a thin client over peinit's control socket at /run/services/peinit/control.sock. The wire protocol — not the CLI — is the normative interface, so everything here (commands, rights, semantics) holds regardless of which front-end you use.
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 peiosctl:
- Every command is access-controlled. 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.
Service commands #
| Command | Does | Required right |
|---|---|---|
start | Run the service through its full start sequence. | SERVICE_START |
stop | SIGTERM, then SIGKILL after StopTimeout. | SERVICE_STOP |
restart | Stop then start, as one operation. | SERVICE_STOP + SERVICE_START |
reload | Re-read configuration (ExecReload, or SIGHUP). | SERVICE_INTERROGATE |
reset | Clear Failed/Abandoned/Skipped → Inactive. | SERVICE_STOP |
status | Report state, cause, PID, uptime, health, current job and operation, warnings. | SERVICE_QUERY_STATUS |
list | List services and states (filtered to what you can query). | (per-service SERVICE_QUERY_STATUS) |
$ peiosctl restart jellyfin
$ peiosctl reload nginx
$ peiosctl reset failed-migration # clear a Failed state without starting
System commands #
| Command | Does | Required right |
|---|---|---|
shutdown <type> | Graceful shutdown. type is poweroff, reboot, or halt. | SYSTEM_SHUTDOWN |
reload-config | Re-read all definitions and rebuild the graph (atomic). | SYSTEM_RELOAD_CONFIG |
operation-status <id> | Report the state of an operation by GUID. | SERVICE_QUERY_STATUS on its target |
$ peiosctl shutdown poweroff
$ peiosctl reload-config
$ peiosctl operation-status a1b2c3d4-...
Wait semantics #
By default, a lifecycle command blocks until its operation reaches a terminal state — start waits for Active (or Failed), stop waits for Inactive, and so on. Pass --no-wait to get the operation_id back immediately and poll with operation-status instead.
| Command | Default | Waits for |
|---|---|---|
start | wait | Active (Simple) / Completed or Inactive (Oneshot), or Failed |
stop | wait | Inactive |
restart | wait | the successful start target, or Failed |
reload | no-wait | (with --wait) the Reloading state to resolve |
reset | immediate | — |
reload is the exception — it returns immediately by default, because a reload may have no observable completion. With --wait, the response carries a mode:
mode | Meaning |
|---|---|
confirmed | The service signalled READY=1 (and, for a command reload, the command exited 0). |
advisory | The reload was issued and the detection window elapsed without explicit confirmation. |
failed | The ExecReload command exited non-zero or timed out. The service stays Active — a failed reload never takes down a running service. |
The detection window is a fixed 2 seconds — it is built in and is not configurable via the registry. If the service says nothing within that window, the reload resolves as advisory and the service stays Active.
The command × state matrix #
A command sent to a service in an unexpected state returns an error, not a silent no-op. This matrix is the authority on what each command does in each state:
| Command | Inactive | Starting | Active | Reloading | Stopping | Completed | Backoff | Failed | Abandoned | Skipped |
|---|---|---|---|---|---|---|---|---|---|---|
| start | Start | MERGE | ALREADY | ALREADY | QUEUE | Start | DEFER | Start | ERROR | Start |
| stop | NOOP | Cancel+Stop | Stop | Stop | MERGE | Clear | Cancel | NOOP | ERROR | NOOP |
| restart | Start | QUEUE | Restart | Restart | QUEUE | Start | Restart | Start | ERROR | Start |
| reload | ERROR | ERROR | Reload | MERGE | ERROR | ERROR | ERROR | ERROR | ERROR | ERROR |
| reset | NOOP | ERROR | ERROR | ERROR | ERROR | ERROR | ERROR | Clear | Clear | Clear |
| status | OK | OK | OK | OK | OK | OK | OK | OK | OK | OK |
Legend:
- MERGE — an operation of this type is already running; your command merges into it and you get its GUID.
- DEFER — your
startis accepted and creates a Pending start operation, but it does not run yet: it waits out the service's existing backoff deadline and then starts. If a deferred start is already waiting, you merge into it and get its GUID. - ALREADY — already in the target state; returns the current status, not an error.
- QUEUE — queued as Pending; runs after the current operation finishes.
- NOOP — no effect; returns the current status.
- Clear / Cancel — clear the state to Inactive / abort the current operation, then proceed.
- ERROR — invalid for this state; returns an error with an explanation.
The Backoff column is the subtle one: the service is down with an automatic restart pending, so start is deferred — it creates a Pending start that honours the remaining backoff delay and only runs once that deadline expires (a second start merges into the one already waiting), stop cancels the pending restart and any deferred start (the service goes Inactive), restart cancels the automatic one and does an admin restart, and reload/reset are invalid because no process exists.
Reading status #
status returns the full picture for one service:
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).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.
list returns a compact summary of every service you can query — services you lack SERVICE_QUERY_STATUS on are simply omitted, not denied:
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.)
Error codes #
An error response is {"status": "error", "code": "...", "message": "..."}. The code is one of:
| Code | Meaning |
|---|---|
ACCESS_DENIED | AccessCheck denied the command against the target descriptor. |
UNKNOWN_SERVICE | No such service definition (also returned for start/restart/reload on a definition-removed service). |
UNKNOWN_OPERATION | No such operation GUID — never existed, or dropped after its retention grace. |
MALFORMED_REQUEST | The request line is not a single valid JSON object. |
REQUEST_TOO_LARGE | The request exceeds MaxRequestSize. |
INVALID_COMMAND | The command field is missing or unknown. |
INVALID_ARGUMENTS | A required field is missing or malformed (e.g. shutdown with no valid type). |
INVALID_STATE | Not valid for the service's current state (an ERROR cell above), or rejected because the system is already shutting down. |
OPERATION_TIMEOUT | A --wait operation did not reach a terminal state within its timeout. |
INTERNAL_ERROR | peinit hit an internal failure executing the command. |
The message is human-readable and non-normative — read it for context, key off the code.
Connection limits #
peinit enforces hard limits on the control socket, all tunable under Machine\System\Init\:
| Key | Default | Limits |
|---|---|---|
MaxControlConnections | 32 | Concurrent client connections (excess are refused at connect). |
MaxRequestSize | 65536 | Bytes per request. |
ConnectionTimeout | 30 | Seconds an idle connection may sit before it is closed. |
Exit status #
| Code | Meaning |
|---|---|
0 | The command succeeded. |
1 | A usage error. |
| non-zero | The command failed — an access denial, unknown service, invalid state, or timeout. |
Where to start #
To understand the states the matrix refers to, read The service lifecycle.
To understand the operations every lifecycle command creates and how to poll them, read Jobs and operations.
To configure who may run each command, read Who can manage a service.
Who can manage a service
Peios / Using Peios / Services & jobs
A service is a securable object. Just as a file or a registry key carries a security descriptor that decides who may touch it, a service carries one that decides who may start, stop, query, or reload it. peinit enforces it on every control command — there is no command that skips the check.
This is the second, independent half of the security model. The first half, Service identity and privileges, is about what a service can do (its token). This page is about who can manage the service — a completely separate question, answered by a completely separate descriptor.
Two descriptors, two questions #
A service is associated with two descriptors that are easy to conflate but answer different questions and are enforced by different components:
| Descriptor | Question | Stored | Enforced by |
|---|---|---|---|
| Registry key SD | Who can read or edit the definition? | On the Machine\System\Services\<name> key | LCS, at key-open time |
| ServiceSecurity SD | Who can manage the running service? | As the ServiceSecurity binary value on that key | peinit, on every control command |
The registry key SD is ordinary registry access control and not peinit's concern — peinit reads definitions as SYSTEM, which has full access. The ServiceSecurity SD is peinit's domain, and the rest of this page is about it.
They are genuinely independent. An administrator might be able to query a service's status (ServiceSecurity grants SERVICE_QUERY_STATUS) but not read its configuration (the registry key SD denies read) — or the reverse. Runtime control and configuration access are separate concerns, and both combinations are valid.
Service access rights #
The ServiceSecurity descriptor grants these rights:
| Right | Bit | Grants |
|---|---|---|
SERVICE_QUERY_STATUS | 0x0001 | Query state, PID, cause, health, warnings. |
SERVICE_START | 0x0002 | Start the service. |
SERVICE_STOP | 0x0004 | Stop the service. |
SERVICE_INTERROGATE | 0x0008 | Reload the service. |
SERVICE_ALL_ACCESS | 0x000F | All of the above — the "full access" granted to SYSTEM by default. |
restart requires both SERVICE_STOP and SERVICE_START, since it is a stop followed by a start. reset requires SERVICE_STOP.
When peinit evaluates the descriptor it maps the generic rights as follows, so a descriptor written with generic rights behaves sensibly:
| Generic | 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 |
How a command is authorised #
Every control command runs the same gate:
- peinit captures the caller's token from the kernel (
kacs_open_peer_token) — the caller's effective identity at connection time, so if the caller is impersonating, the impersonated identity is what is checked. - peinit resolves the target service and its ServiceSecurity descriptor.
- peinit runs AccessCheck: the caller's token against the descriptor, for the right the command needs.
- Denied → return
ACCESS_DENIEDand log the attempt (caller SID, target service, requested right). - Granted → execute the command.
The default descriptor #
If a service has no ServiceSecurity value, it inherits its parent key's. If no ancestor sets one either, peinit applies a built-in default:
- SYSTEM (
S-1-5-18) — full access. - Administrators (
S-1-5-32-544) — query and stop only.
So out of the box, administrators can see and stop a service but not start or reload it unless a descriptor grants more — a conservative default that you widen deliberately.
ServiceSecurity is hot-reloaded: a change to the value in the registry takes effect on the next control request, with no service restart. peinit picks the change up through a registry notification. This is why ServiceSecurity is in its own mutability class — access policy should be able to change without disturbing a running service.
The system control descriptor #
Some operations are not about any one service — shutdown and reload-config act on the whole system. These are checked against peinit's own descriptor, stored at Machine\System\Init\ControlSecurity:
| Right | Bit | Grants |
|---|---|---|
SYSTEM_SHUTDOWN | 0x0001 | Initiate poweroff, reboot, or halt. |
SYSTEM_RELOAD_CONFIG | 0x0002 | Re-read all definitions and rebuild the graph. |
Its generic mapping deliberately gives GENERIC_READ nothing — there is no "read" of the system control object, only the two actions:
| Generic | Maps to |
|---|---|
GENERIC_READ | (nothing) |
GENERIC_WRITE | SYSTEM_RELOAD_CONFIG |
GENERIC_EXECUTE | SYSTEM_SHUTDOWN |
GENERIC_ALL | SYSTEM_SHUTDOWN | SYSTEM_RELOAD_CONFIG |
The default grants SYSTEM full access and Administrators both rights. peinit loads this descriptor at boot and hot-reloads it on registry change, exactly like ServiceSecurity.
The list command filters, it does not deny #
list is access-control-aware in a quieter way: it returns only the services the caller has SERVICE_QUERY_STATUS on, and simply omits the rest. A caller with no query rights gets an empty list, not a denial. This means a low-privilege principal cannot even enumerate the services it cannot see — the existence of a service is itself information the descriptor controls.
The boundaries that hold #
A few invariants are worth stating outright, because they are what make this trustworthy:
- peinit never bypasses AccessCheck for a control operation. No backdoor, no override flag, no "trust localhost."
- The descriptors are the only policy inputs. peinit consults the ServiceSecurity and ControlSecurity descriptors and nothing else — not config files, not environment variables, not hardcoded lists.
- One service's state is never exposed to another without a check.
statusis per-service access-controlled;listfilters.
Where to start #
For the other half of the security model — what a service can reach once it is running — read Service identity and privileges.
For the commands these rights gate, read Controlling services.
For the descriptor and AccessCheck machinery itself, read Security descriptors and Access decisions.
Jobs and operations
Peios / Using Peios / Services & jobs
A service is a long-lived definition. But when you read a status output or query the event log, two shorter-lived objects show up alongside it: the job (one process execution) and the operation (one requested action). They are what make peinit observable — every run and every command carries a GUID you can follow. This page covers both, and the special case of ad-hoc jobs.
Jobs: what actually ran #
Every time peinit forks a process — a service's main binary, a pre- or post-hook, a health check, or an ad-hoc run — that single execution is a job, with its own GUID and exit result. If a service is the what, a job is what actually ran. A restart does not reuse a job; it creates a new one. The service always tracks its current job GUID, and a status query reports it.
Jobs come in five types:
| Type | Created for |
|---|---|
ServiceMain | A service's main process. |
PreExecHook | One ExecStartPre command. |
PostExecHook | One ExecStartPost command. |
HealthCheck | One health-check run. |
AdHoc | A process submitted via JFS. |
Their lifecycle is simpler than a service's, because a job only tracks a process — not policy:
| State | Meaning |
|---|---|
Created | The job object exists but the process is not forked yet (pre-hooks may be running). |
Running | The process is alive. |
Completed | The process exited successfully. |
Failed | The process failed — or peinit classified the job failed before fork (a parent-side setup error). |
Abandoned | The process survived SIGKILL (D-state). |
A job record carries the things you would want for forensics: the resolved identity and a token summary, the image path and arguments, created/started/ended timestamps, the exit code or signal, the cgroup, the failure cause, and the operation_id that created it (null for ad-hoc jobs). The clean division of ownership is: the service owns policy (restart, dependencies, health schedule, current state); the job owns the execution facts (PID, exit, timestamps, identity, cgroup, log correlation).
Retention and log correlation #
peinit keeps only active jobs in memory. When a job reaches a terminal state it emits a structured event (through KMES) carrying the full record, then drops the job. peinit keeps no job history — eventd is the historian, consuming those events from the kernel ring buffer. The lifecycle events are job.created, job.started, and job.ended.
Separately, all of a job's stdout/stderr is forwarded to eventd tagged with the job's GUID. That is what lets a query like "show me the logs for job X" return exactly that execution's output — not interleaved with the run before or after it.
Ad-hoc jobs #
An ad-hoc job is an arbitrary process a service asks peinit to run on behalf of one of its clients. It is not tied to a persistent definition — it runs once, reports its result, and is cleaned up. The motivating case: a service has impersonated a user and wants a process run as that user, but supervised centrally by peinit rather than by the service itself.
The obstacle is identity delegation: a service cannot simply forward an impersonation token over a socket. JFS (the Job Forwarding Subsystem) solves this in the kernel — it captures the caller's effective token and delivers it to peinit along with the job request. peinit forks, installs that captured token, and supervises the result like any other job.
An ad-hoc request carries a deliberately small subset of the service fields — ImagePath, Arguments, Environment, WorkingDirectory, Timeout, Description. A couple are worth pinning down: Timeout is in seconds, and 0 means no limit (the job runs for as long as it needs). WorkingDirectory defaults to / when you leave it out. The policy fields (restart, dependencies, health checks, ErrorControl, triggers) are not available — those belong to persistent definitions. An ad-hoc job runs once in its own cgroup, routes its output to eventd, and on exit emits job.ended and is dropped. If it overruns its Timeout, peinit escalates SIGTERM → SIGKILL exactly as a service stop does.
Ad-hoc jobs bypass the operation model entirely — there is no service to "start," so the request creates a job directly. The job is the whole lifecycle.
Operations: what was requested #
An operation is a first-class object representing a requested state-machine action on a service. Rather than letting control commands mutate state directly, peinit turns each one into an operation that is validated, queued, and executed by its event loop. This is what gives concurrent callers — admin tools, automated triggers, dependency propagation — explicit, observable conflict resolution instead of races.
There are five operation types — Start, Stop, Restart, Reload, Reset — and each carries a source recording why peinit created it:
| Source | Created by |
|---|---|
Admin | A control client. |
Boot | The Phase 2 boot lifecycle. |
Shutdown | The shutdown lifecycle. |
DependencyPropagation | A start pulling in a dependency. |
RestartPolicy | An automatic restart. |
Timer | A timer firing. |
BindsToRecovery | A bound target returning to Active. |
BindsToPropagation | A bound target stopping. |
ConflictResolution | A Conflicts eviction. |
OnFailure | A failed service's fallback handler. |
The source is why a status showing current_operation.source: "restart_policy" tells you the service is being auto-restarted, while "admin" tells you a person asked.
Operation lifecycle #
flowchart LR
P["Pending"] --> R["Running"]
R --> C["Completed"]
R --> F["Failed"]
R --> A["Aborted"]
P --> M["Merged"]
P --> X["Cancelled"]
P --> F
| State | Meaning |
|---|---|
Pending | Validated and queued, waiting on a precondition (e.g. a prior stop to finish). |
Running | Executing — the service is transitioning. |
Completed | Achieved its goal (start reached Active/Completed; stop reached Inactive/Failed). |
Failed | Did not achieve its goal — including timing out while still Pending. |
Merged | Folded into an identical operation already in flight (records the survivor's GUID). |
Cancelled | Terminated while still Pending — never ran. |
Aborted | Terminated while Running — interrupted in progress. |
Cancelled and Aborted are the same idea at two points: cancelled never ran, aborted was running. The reason (admin action, supersession) is a property of the event, not the state.
Conflict resolution and merging #
When a command arrives for a service that already has an operation in flight, peinit resolves the collision deterministically — the same logic the command × state matrix summarises:
- Same type (start over start, stop over stop, reload over reload) → merge. The new caller transparently receives the existing operation's GUID; from their side, their request is in progress.
- Stop wins over start. An explicit stop cancels a pending start or aborts a running one. The admin said stop.
- Later supersedes earlier, and a start while a stop is in flight is queued to run after the stop.
Timeout and retention #
An operation inherits its target's timeout as its maximum lifetime — StartTimeout for start/reload/reset, StopTimeout for stop, and the sum of both legs for restart. Crucially, the clock starts at operation creation, including queue time — from the caller's perspective they have been waiting since they sent the command, so a long queue can time an operation out before it even runs.
Terminal operations are emitted as events (operation.requested, .started, .completed, .failed, .cancelled, .merged, .aborted) and dropped from memory after a short grace (default 60 s — long enough for a polling client to read the result). As with jobs, peinit keeps no operation history; eventd does.
How they surface #
You meet jobs and operations in three places:
status—current_jobandcurrent_operationgive the GUIDs of the service's active execution and active action (ornull).operation-status <id>— polls one operation to a terminal state; a--waitlifecycle command does this for you under the hood.- eventd — the durable history. Every job and operation lifecycle transition lands there as a structured event, which is how you reconstruct what happened after the objects themselves are gone from peinit's memory.
Where to start #
To create and poll operations from the command line, read Controlling services.
To understand the service states an operation drives a service through, read The service lifecycle.
To query the durable job and operation history, read Auditing.
Service output and logging
Peios / Using Peios / Services & jobs
peinit is not a logging system — storage, indexing, and queries belong to eventd. But peinit holds the stdout/stderr pipes of every service at the moment it forks, so it is unavoidably in charge of where output goes, and it has to cope with the window early in boot before eventd even exists. This page is about that responsibility.
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.
Logs are best-effort; audit events are not #
This is the distinction to internalise, because it explains why some things survive a crash and others do not. peinit handles two different streams of information with two different guarantees:
| Service logs | Audit events | |
|---|---|---|
| What | stdout/stderr from services and hooks | Access denials, critical failures, recovery-mode entry, graph errors, job/operation lifecycle |
| Path | Forwarded to eventd's log socket | Emitted into the KMES kernel ring buffer |
| Guarantee | Best-effort — may be dropped under load | Durable — survive eventd restarts and reboots |
| Available from | Once eventd is up (buffered before) | The moment PKM loads — before eventd, before the registry |
Service logs are a convenience: useful, but lossy by design. Audit events are part of the security posture and go through KMES, which persists them in the kernel from the instant the module loads. So peinit keeps a pre-eventd buffer for logs but needs none for events — eventd picks events up from the ring buffer whenever it attaches, no matter how late.
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.
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-iteration read budget. Each pass of the event loop reads at most a bounded number of bytes from service pipes before moving on to other events — so no single chatty service can monopolise a loop iteration.
- 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.
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 a line, tag it, forward it as a datagram. 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 is a log gap across the restart, bounded by the buffer size — but audit events are unaffected, because they were going to KMES the whole time and eventd resumes consuming them from the last persisted sequence.
Console output #
peinit writes its own operational messages to /dev/console:
- Phase 1 progress (mount results, registryd start),
- Phase 2 progress (service starts and failures, dependency errors),
- shutdown progress,
- recovery-mode entry,
- critical service failures.
Service stdout/stderr is not echoed to the console by default — the console is for peinit's status messages only. This keeps the console legible during boot and, crucially, usable in Recovery mode, where it may be the only interface an administrator has.
Where to start #
To see how the pipes are wired and what else the process inherits, read The execution environment.
To follow the job GUID that tags every log line, read Jobs and operations.
For where the logs and events actually go — storage, indexing, and queries — read Auditing.
Boot and boot modes
Peios / Using Peios / Services & jobs
peinit's boot has two phases and a chicken-and-egg problem to solve. The problem: peinit reads everything it does from the registry, but the registry is served by a daemon that something has to start first — and the identity authority that would mint that daemon's token does not exist yet either. The solution is to split boot into a hardcoded bootstrap that needs no registry, and a registry-driven phase that starts once the registry is up. The boundary between them is registryd.
Where peinit takes over #
peinit is PID 1, but it is not the first thing that runs. The initramfs assembles and mounts the real root — decryption, RAID/LVM, the root filesystem itself — and then hands control to peinit. The contract peinit relies on is narrow:
- The real root is already mounted read-write, and
/proc,/sys,/devare mounted and moved into it. - peinit is exec'd as PID 1 from a fixed path on the real root.
peinit does not assemble, decrypt, repair, or even re-mount the root — those need tools and configuration that belong to the initramfs. It also does not fsck the root or mount non-root storage (a data partition is mounted by an ordinary Oneshot service, not by peinit). For the trust and identity side of this handoff — signatures, the SYSTEM token peinit inherits — see peinit at PID 1.
Phase 1: the hardcoded bootstrap #
Phase 1 is compiled into peinit. It cannot change at runtime and touches no registry. It does the minimum to make Phase 2 possible:
- Confirm the root is writable with a single probe write. registryd's storage needs a writable root even for reads, so a read-only root cannot support Phase 2 → Recovery.
- Mount the remaining virtual filesystems —
/dev/pts,/dev/shm,/run,/sys/fs/cgroup— mounting each only if absent. A failure here → Recovery. - Restore the persisted random seed from
/var/state/peinit/random-seed, mixing it into the kernel's entropy pool early so anything that needs randomness during boot gets it. A missing seed is normal — first boots and stateless live boots have none — so peinit just carries on; a seed problem is never fatal and never sends boot to Recovery. - Establish the machine-id from
/lcl/etc/machine-id— a stable, opaque identifier for this install (used for log correlation, instance identity, and software compatibility). It is not a security principal: it is not a SID, an account, or a credential, and no authorisation decision depends on it. If the file is missing, empty, or malformed, peinit generates a fresh 128-bit ID and writes it before continuing. - Set the clock from the hardware RTC, so early timestamps and the boot counter are meaningful. A failure here → Recovery.
- Start registryd and wait for it to signal readiness, then probe-read the schema-version key to confirm it is actually serving reads. Any failure → Recovery — there is no Phase 2 without a registry.
- Provision boot-time paths. With registryd up and before Phase 2 starts, peinit applies the entries under
Machine\System\Init\ProvisionedPaths\— the registry-driven equivalent of tmpfiles.d, creating directories and files (with Peios security descriptors) that no single service owns. Best-effort entries that fail are logged and skipped, but an entry markedRequired=1that cannot be provisioned sends boot → Recovery. The individual keys are cataloged in the registry key reference. - Infrastructure setup — create the control socket, open the JFS device for ad-hoc jobs, bring up the loopback interface. Control-socket failure → Recovery; the other two are logged as warnings and boot continues.
Most Phase 1 failures are fatal to a normal boot, because none of the later machinery can run without this foundation — the only outcome is Recovery mode. The exceptions are the fail-soft steps called out above: a missing or unusable random seed, a regenerated machine-id, and best-effort provisioned paths all let boot continue.
registryd and loregd #
registryd is an interface, not a specific program. It is the path peinit execs to get a registry source daemon — the component that implements the registry's persistent storage and answers peinit's reads. The implementation behind that interface can vary; by default it is loregd.
This split matters in exactly one place: Recovery mode. In normal operation you only ever deal with the registryd abstraction — peinit starts it, treats it as opaque, and reads the registry through it. But when the registry itself is what broke, you need tools that work without a running registry, and those tools talk to the implementation directly. That is why the recovery tooling is named loregd (loregd --inspector, --recover-from-backup, …): in recovery you are working with the storage implementation, not the registry abstraction. It is the one context where the distinction is visible to an administrator.
Phase 2: the registry-driven boot #
With registryd serving reads, peinit boots the rest of the system from the registry:
- Read all definitions under
Machine\System\Services\. (A registry read timing out here → Recovery.) - Build and validate the dependency graph from the boot-triggered services and their transitive dependency closure. Validation runs before anything starts.
- Start services in dependency order, in parallel up to
MaxParallelStarts, with readiness gating releasing each service's dependents as it becomes satisfied.
Only services with a boot trigger are start candidates; demand-only services are pulled in only if something boot-triggered depends on them, and Disabled services are excluded from the graph (but kept in the model for on-demand start). The whole boot runs against one snapshot — mid-boot registry edits do not perturb it.
The platform daemons come up first because everything rests on them. They are all SYSTEM, all minted by peinit (no authd yet), and all ErrorControl=Critical:
| Service | Phase | Identity source | Readiness | ErrorControl |
|---|---|---|---|---|
| registryd | 1 | minted by peinit | sd_notify | Critical |
| eudev | 2 | minted by peinit (privileges stripped) | process alive | Normal |
| lpsd | 2 | minted by peinit | sd_notify | Critical |
| authd | 2 | minted by peinit | sd_notify | Critical |
| eventd | 2 | minted by peinit | sd_notify | Critical |
| networking | 2 | authd | sd_notify | Normal |
| sshd | 2 | authd | process alive | Normal |
| application services | 2 | authd | per-service | Normal |
Once authd is up, every subsequent service gets its token through the normal authd flow. The order above is emergent from the standard role definitions' dependencies, not hardcoded — change the dependencies and the order changes. In practice login services such as sshd come up last, so the system is fully operational before it starts accepting user sessions.
The three boot modes #
The three modes form an escalation path from normal operation to last-resort maintenance.
flowchart TD
F["Full boot"] -->|success| OK["operational<br/>counter reset"]
F -->|cycle/conflict w/ Critical| S["Safe mode<br/>(no reboot)"]
F -->|Critical failure| RB["sync + reboot"]
S -->|success| OKS["operational (reduced)<br/>counter reset"]
S -->|Critical failure| RB
RB --> CNT["counter increments"]
CNT -->|counter ≥ N| REC["Recovery mode"]
F -.->|Phase 1 failure| REC
Full mode #
The default. All boot-triggered services start in dependency order. A boot is successful — and the boot-attempt counter resets to 0 — when every Critical service has reached and held a dependent-satisfying state (Active, Completed, or Skipped) for a grace period (BootSuccessGrace, default 30 s). The criterion is "satisfying," not "Active," so that a Critical Oneshot that reaches Completed can still mark the boot good.
Safe mode #
Safe mode starts a reduced set of boot-triggered services and rebuilds the graph from scratch using only those, dropping dependencies on excluded services. Two categories are eligible:
- Critical services (
ErrorControl=Critical) — must start; a Critical failure here still follows the normal reboot path. - SafeMode services (
SafeMode=1) — best-effort; if they fail, Safe mode carries on without them.
Everything else is skipped. Safe mode is entered when:
- graph validation finds a cycle involving a Critical service, or
- an unresolvable conflict involving a Critical service, or
- the kernel command line says
peios.safemode=1.
The first two are configuration errors — rebooting would just hit them again — so peinit downgrades without rebooting. Safe mode is not entered by a Critical service crashing at runtime; that follows the reboot path. And it is purely a boot-sequencing concern: once booted, you can start any service by hand exactly as in Full mode. It is the mode for "the configuration is broken but the TCB is healthy."
Recovery mode #
Recovery mode is the last resort, and it offers no TCB guarantee — the administrator gets an unrestricted SYSTEM shell on the console and must treat it with corresponding care. It is a maintenance environment, not a degraded boot.
In Recovery, peinit completes the Phase 1 basics, tries to start registryd (ignoring failure — the shell must appear regardless), skips all Phase 2 services, and execs a SYSTEM shell on /dev/console (/bin/recsh if present, else /bin/sh), respawning it if it exits. It is entered when:
- the boot-attempt counter reaches N (default 3),
- the kernel command line says
peios.recovery=1, or - registryd fails during Phase 1 — entered immediately, with no reboot and no counter increment, because there is no Phase 2 to attempt.
Because the registry itself may be what broke, recovery provides tools that work without it — talking to the loregd implementation directly:
| Tool | Does |
|---|---|
loregd --inspector | Read the storage database directly for diagnosis. |
loregd --recover-from-backup | Restore from an automatic backup taken on every registryd startup. |
loregd --dangerously-clear-database | Wipe the registry entirely. Recoverable, because role definitions are the source of truth for service config. |
The boot-attempt counter #
The counter is what turns a crash-looping Critical service into an eventual Recovery shell instead of an infinite reboot loop. It is a plain integer in a file at /.peinit/boot-attempts — not in the registry, because the registry may be the very thing that is broken.
- peinit reads it at startup, before choosing a mode. The Recovery threshold (
counter ≥ N) is checked against this pre-increment value, so the default N of 3 admits exactly three attempts before Recovery. A missing file counts as 0; a corrupt or unreadable one → Recovery. Override N withpeios.bootattempts=Non the kernel command line, or set it to0to disable the check when the counter is itself the fault. - peinit increments it once per boot, right after confirming the root is writable and before Phase 2 — never before the root is known writable, or a read-only root would silently lose the increment and defeat escalation.
- The counter is reset to 0 on a successful Full or Safe boot (after the grace period).
- If the counter file cannot be written (disk full), peinit treats it as 0 and continues — a write failure must not by itself trigger Recovery.
A Critical service exhausting its restart budget — at boot or at runtime — triggers a sync and reboot, which increments the counter on the next boot. Repeat that enough and the counter crosses N, and peinit stops trying and hands you a Recovery shell. The counter deliberately does not try to catch a peinit too broken to reach its own increment; that is a binary-integrity problem, not a boot-loop problem.
Boot configuration #
| Key | Default | Controls |
|---|---|---|
Machine\System\Boot\MaxParallelStarts | 10 | Services starting concurrently (must be > 0; invalid → Recovery). |
Machine\System\Boot\BootSuccessGrace | 30 | Seconds a Critical service must hold a satisfying state before boot counts as successful. |
Machine\System\Boot\ShutdownTimeout | 90 | Maximum seconds for the whole shutdown sequence. |
Machine\System\Boot\PostKillTimeout | 5 | Seconds a service cgroup may take to drain after SIGKILL before it counts as stuck. |
The kernel command line #
peinit reads four peios.* tokens. They are deliberately few: everything peinit can read after registryd is serving belongs in the registry instead, where it can be inspected, secured and changed without editing a boot entry. What is left is either a per-boot mode decision or a Phase 1 value — one peinit needs before there is a registry to ask.
| Token | Effect |
|---|---|
peios.safemode=1 | Force Safe mode. |
peios.recovery=1 | Force Recovery mode, whatever the counter says. |
peios.bootattempts=N | Override the boot-attempt threshold; 0 disables the check. |
peios.notifysocket=PATH | Move the sd_notify socket. Phase 1, because registryd is the first service to use it and it must be bound before registryd starts. |
peios.quiet=N | How much peinit may write to the console: 0 write everything, 1 (default) stay out of a terminal a service owns, 2 also drop ordinary progress. See below. |
Console noise: peios.quiet #
peinit reports its progress to /dev/console, and so does any service that claims that terminal with TTYPath. Two writers, one device: a login prompt started while peinit is still narrating gets written over, and the reader cannot tell input from log. peios.quiet decides who yields.
It sets two independent rules, which is why it is a level and not a flag:
| Terminal a service owns | Everywhere else | |
|---|---|---|
peios.quiet=0 | peinit writes anyway | everything |
peios.quiet=1 (default) | only messages that mean the machine is about to be lost | everything |
peios.quiet=2 | only messages that mean the machine is about to be lost | errors only |
The rules stack rather than scale: errors are never less visible at 2 than at 1. An error overrides a requested blackout — silence was a preference, and an error is news — but it does not override terminal ownership, which is not peinit's to override. Only losing the machine (dropping to Recovery, halting with no shell, a Critical service about to force a reboot) is worth one corrupted line of somebody else's session.
prelude honours peios.quiet=2 as well, since it writes to the same console. The other two levels mean nothing there — prelude exits before the first service starts, so no terminal has an owner yet.
A few lines escape all of this: whatever peinit and prelude print before they have read the command line. Neither can honour a preference it has not seen yet, and those lines are also the only evidence either started at all.
Unknown peios.* tokens are ignored, as is a malformed value on either of the two valued tokens — this parser runs before anything exists to report a diagnostic to, and refusing to boot over a typo in a tuning knob is the worse outcome.
Where to start #
To understand the graph validation and parallel start that drive Phase 2, read Dependencies and ordering.
To understand the Critical-failure reboot path that feeds the boot counter, read Keeping services running.
For the trust and token side of early boot, read peinit at PID 1.
Shutdown
Peios / Using Peios / Services & jobs
Shutdown is boot run in reverse. peinit stops services in reverse dependency order — dependents before the things they depend on — escalating from a polite SIGTERM to a forced SIGKILL, all bounded by a global timeout, and then unmounts, syncs, and performs the final power action. This page covers how it is triggered and how it runs.
How shutdown is triggered #
There are four paths into shutdown, and they are not equal — three are graceful, one is not.
A control command. An administrator with SYSTEM_SHUTDOWN runs peiosctl shutdown <type>:
| Type | Result |
|---|---|
poweroff | Stop everything, unmount, power off. |
reboot | Stop everything, unmount, reboot. |
halt | Stop everything, unmount, halt (CPU stopped, power stays on). |
Signals. As PID 1, peinit treats a handful of signals as shutdown requests:
| Signal | Meaning |
|---|---|
| SIGINT | Reboot — the kernel sends this on Ctrl-Alt-Del. |
| SIGTERM | Poweroff. |
| SIGPWR | Poweroff — a compatibility path for environments that surface power failure or power-button policy as a signal. |
The power button. On Linux, a physical power-button press is a graceful poweroff — peinit watches the machine's input devices under /dev/input and treats a KEY_POWER press (the key going down, not its release or auto-repeat) as a shutdown request. This path is deliberately minimal and fail-soft: if /dev/input is missing, an input device cannot be opened, or a device stops responding, peinit just keeps running and drops that device — the button quietly stops working, but the machine stays up and you can still shut down over the control socket or with a signal. It is not a full power-management policy engine; a future acpid/logind-style daemon can layer richer policy on top by sending control-socket commands.
A Critical service failure — the ungraceful path. When an ErrorControl=Critical service exhausts its restart budget, peinit syncs and reboots immediately. There is no service-stop ordering: the system is in an undefined state and the fastest route to a known one is a reboot. This is what feeds the boot-attempt counter, and a Critical service that fails identically every boot becomes a reboot loop that the counter eventually breaks by escalating to Recovery.
The graceful sequence #
For a poweroff, reboot, or halt, peinit runs an ordered sequence:
- Enter the shutdown state. A flag is set, and while it holds: no new services may start, timer triggers are disarmed, and new control commands are rejected with
INVALID_STATE— exceptstatusqueries, which keep working so you can watch progress. - Suspend Critical-failure semantics. If a Critical service fails during shutdown, it is logged but does not trigger a reboot — the system is already going down, and rebooting would loop.
- Clear Completed services. Oneshot services sitting in Completed (with
RemainAfterExit) have no process; peinit moves them to Inactive so their dependents can be stopped cleanly. - Stop services in reverse dependency order, in waves. Each graceful-stop-eligible service (those in Active or Reloading) gets SIGTERM and
StopTimeoutseconds to exit; if it does not, peinit SIGKILLs its whole cgroup. A service is never stopped until everything thatRequiresorBindsToit has already stopped. Services that are Starting are not stopped gracefully — their startup is cancelled and the cgroup SIGKILLed. - Enforce the global timeout. The whole sequence is bounded by
ShutdownTimeout(default 90 s). When it expires, all remaining services are SIGKILLed; any whose cgroups do not empty within the post-kill timeout (default 5 s) become Abandoned (leaked), and shutdown continues regardless. - Save the random seed. With every service stopped and before any filesystem is touched, peinit writes a fresh seed drawn from the kernel's CSPRNG to
/var/state/peinit/random-seed— this is the entropy the next boot starts from. The write is crash-conscious (written to a temporary file, flushed, then atomically swapped in), and it is best-effort: if it fails, peinit logs it and carries on. Shutdown never stalls on it, and the forced and Critical-reboot paths skip it entirely in favour of getting the machine down fast. - Unmount filesystems. peinit snapshots the mount table and tries to unmount every remaining non-root mount in its namespace — not just the ones it mounted itself — working from the deepest mount points outward. That includes the Phase 1 set (
/proc,/sys,/dev,/dev/pts,/dev/shm,/run,/sys/fs/cgroup) wherever those are still mounted. A mount that is already gone counts as done. If one won't unmount because it is busy, peinit falls back to remounting it read-only; anything it still can't clean up is logged and left for the finalsync()and the kernel, never blocking shutdown. The root filesystem (/) is never unmounted — once the rest are handled, peinit remounts root read-only. - Sync and finish.
sync()to flush pending writes, then the final action — power off, reboot, or halt.
The reverse ordering falls straight out of the dependency graph; there is no separate stop-order configuration. The last services to stop are the TCB daemons everything rests on — eventd, then authd, then lpsd, then registryd dead last, mirroring its position as the first ever started.
STOPPING=1 and timeout extension #
A service that begins shutting down on its own — say, in response to an internal error — can tell peinit by sending STOPPING=1 via sd_notify. peinit acknowledges it and, importantly, stops sending SIGTERM to that service: it is already on its way down, and a redundant signal could interfere.
STOPPING=1 is a courtesy, not a timeout extension. The StopTimeout keeps running; if the process has not exited when it expires, peinit escalates to SIGKILL regardless. A service that genuinely needs longer must say so with EXTEND_TIMEOUT_USEC (see Keeping services running) — and during shutdown those extensions are capped not only by the per-service StopTimeout × 4 but also by the remaining global ShutdownTimeout, whichever is smaller.
Forced shutdown #
When something is wedged and you need the machine down now, repeated SIGINT forces it: three Ctrl-Alt-Del presses within five seconds skip the graceful sequence entirely — SIGKILL everything, sync, reboot. It is the escape hatch for a graceful shutdown that is itself stuck behind an unresponsive service.
Shutdown during boot #
A shutdown requested while peinit is still in Phase 2 boot takes effect immediately: services still Starting are SIGKILLed (they never reached Active, so there is nothing to stop gracefully), services that did reach Active are stopped per the normal sequence, and the boot is abandoned.
How peinit handles signals #
peinit handles all signals through a signalfd read from its event loop — every signal is blocked and read as data, so there are no async signal handlers and no async-safety hazards. PID 1 cannot be killed by any signal; the kernel protects it.
| Signal | Behaviour |
|---|---|
| SIGCHLD | Reap children; match exits to services and jobs. Also reaps orphaned processes the system reparented to PID 1. |
| SIGINT | Reboot request. Repeated within the window → forced reboot. |
| SIGTERM | Poweroff request. |
| SIGPWR | Poweroff request — compatibility path for power-button/power-failure policy. |
| SIGHUP | Ignored — PID 1 has no controlling terminal. |
| SIGPIPE | Ignored — a broken control-socket pipe must never crash PID 1. |
All other signals are ignored.
Where to start #
To understand the reverse ordering and which relationships gate it, read Dependencies and ordering.
To understand the Critical-failure path that bypasses graceful shutdown, read Keeping services running.
For the boot side of the lifecycle and the reboot/recovery escalation, read Boot and boot modes.
Troubleshooting peinit
Peios / Using Peios / Services & jobs
Almost every peinit problem is diagnosed the same way: read the state and the cause. A status query gives you both, and the cause taxonomy tells you what the cause means. This page works backward from common symptoms to the cause and the fix.
$ peiosctl status <service> # state + cause + health + warnings
$ peiosctl list # everything you can see, at a glance
For history beyond what peinit holds in memory — past jobs, prior failures, the exact log line a service died on — query eventd; peinit emits every job and operation transition there.
A service won't start #
status shows it Failed or Skipped. The cause says why:
| Cause | What happened | What to do |
|---|---|---|
ValidationError | The definition is malformed — a bad field, an unresolvable conflict, an illegal health-check timing. | Check the logs for the specific field. Fix the definition; the message names what is wrong. |
DependencyFailure | A Requires/BindsTo target failed or does not exist. | Fix the target first — status it. The dependent recovers once the target can start. |
ConditionSkipped (→ Skipped) | A start-time condition was not met. Not a failure — the service decided it does not apply here. | If it should run, the condition's premise is false (a missing path/file/key). This is often correct behaviour. |
AssertionError | A start-time assert failed — a required precondition is missing. | Create the missing precondition (path, file, directory, registry key), then start again. |
PreHookFailure | An ExecStartPre hook exited non-zero. | Run the hook command by hand as the hook identity to see why. |
ParentSetupFailure | peinit could not even fork — fd/PID exhaustion, a cgroup error. No process was created. | A system-resource problem, not the service's fault. Check for fd/PID limits and cgroup health. |
PreExecFailure | Setup after fork failed — token install, rlimits, environment. | Usually identity: check Identity, that authd is up, and that RequiredPrivileges names real privileges. |
ReadinessTimeout | The process started but never became ready within StartTimeout. | Either the service is genuinely slow (raise StartTimeout, or have it send EXTEND_TIMEOUT_USEC) or it never sends READY=1 (wrong Readiness, or a bug). |
A service keeps restarting #
The service flaps — up, down, up, down. This is restart policy doing its job, but it points at an unstable service.
- It eventually settles in
FailedwithRestartBudgetExhausted. It crashedRestartMaxRetriestimes faster than it could stay healthy forRestartWindow. The fix is the service, not the policy — read its logs for the crash. Raising the budget only delays the inevitable. - It flaps forever and never exhausts the budget. It is briefly reaching Active each cycle and resetting the counter. Either genuinely stabilise it, or — if a health check is failing it after it starts — check the health command; a flaky check restarts a perfectly good service.
- It restarts on a clean exit you did not expect.
RestartPolicy=Alwaysrestarts even successful exits (causeCleanExitRestart). If the service is meant to exit, useOnFailureinstead.
The machine keeps rebooting #
A reboot loop almost always means a Critical service is failing. A ErrorControl=Critical service that exhausts its budget makes peinit sync and reboot; if it fails the same way every boot, you loop.
The boot-attempt counter is designed to break this: after N attempts (default 3) peinit stops and drops you into Recovery mode. From the Recovery shell, find the failing Critical service (its logs are in eventd, which persists across reboots), fix or disable it, and reboot. If you need to intervene before the counter trips, boot with peios.recovery=1 on the kernel command line.
A service is Abandoned #
Abandoned means peinit SIGKILLed the process but it survived — stuck in uninterruptible kernel sleep (D-state), the signature of hung I/O (a dead NFS mount, a failing disk). Nothing in userspace can kill a D-state process.
- The underlying I/O fault is the real problem — investigate the storage or mount, not peinit.
- The service's cgroup is leaked and shows in
warnings. A later start uses a fresh generational cgroup, so the new instance is unaffected. peiosctl reset <service>clears the Abandoned state and re-checks the cgroup: if the stuck process finally died, peinit cleans up; if not, it stays leaked and warns you. Leaked cgroups clear fully only on reboot.
I changed the config and nothing happened #
peinit works from an in-memory snapshot, and each field has a mutability class that decides when a change applies:
- Immutable at runtime (
ImagePath,Type,Identity,RequiredPrivileges,ErrorControl) → takes effect only onrestart. - Apply on next start (dependencies, conditions,
OnFailure) → next start or graph reload. - Reloadable at runtime (timeouts, restart policy, health checks, environment, hooks…) → next time peinit acts on the service.
- Hot-reloaded (
ServiceSecurity) → next control request.
If a change is not landing, check its class first. For a wholesale re-read of every definition, run peiosctl reload-config — it rebuilds and validates the whole graph atomically and swaps it in only if validation passes (running services are untouched). And remember peinit pulls changes from registry notifications rather than having them pushed, so there can be a brief lag.
ACCESS_DENIED #
A control command returns ACCESS_DENIED. peinit ran AccessCheck on your token against the target's descriptor and the right was not granted:
- A service command needs the matching right in the service's ServiceSecurity descriptor (
startneedsSERVICE_START,restartneeds both stop and start, …). shutdownandreload-configneedSYSTEM_SHUTDOWN/SYSTEM_RELOAD_CONFIGin peinit's control descriptor.- The check uses your effective identity at connect time — if you are impersonating, that is what is checked.
Every denial is logged with the caller SID, target, and requested right. Walk it through Debugging a denial.
A dependent never started #
You started (or booted) a service, but something that depends on it never came up:
- A
Requires/BindsTodependent stays blocked until its target reaches a satisfying state (Active, Completed, or Skipped). If the target is stuck in Starting or Failed, so is the dependent — fix the target. - A dependent that connected to its target on boot and got connection refused usually means the target uses
Readiness=Alive— peinit only waited for the process to exist, not to be serving. Switch the target toReadiness=Notifyso it signalsREADY=1when actually ready. (peinit logs this as a validation warning at boot.) - A
Wantsdependent starts regardless of its target — if you needed it to wait,Wantswas the wrong relationship; useRequires.
Booted into Safe mode #
Safe mode starts only Critical and SafeMode=1 services. You land here when graph validation finds a cycle or unresolvable conflict involving a Critical service, or when peios.safemode=1 is on the kernel command line. The TCB is healthy; the configuration is broken.
Fix the graph: the logs name the cycle path (A → B → C → A) or the conflicting pair. Once the cycle is broken or the conflict resolved, a normal boot returns. You can start any service by hand from Safe mode in the meantime — it is only auto-start that is restricted.
Booted into Recovery mode #
Recovery mode gives you a SYSTEM shell on the console and starts nothing else. You reach it when the boot counter hits N, when peios.recovery=1 is set, or when registryd failed in Phase 1 (entered immediately, no counter).
If the registry itself is the problem, use the offline tools that bypass it — they talk to the loregd implementation directly:
$ loregd --inspector # read the storage directly to diagnose
$ loregd --recover-from-backup # restore the backup taken each registryd start
$ loregd --dangerously-clear-database # last resort — wipe; roles re-supply config
Recovery has no TCB protections — it is a SYSTEM shell, full stop. Treat it accordingly, and remember it needs console access (physical, IPMI, or serial); there is no remote recovery yet.
Where to start #
To read states and causes fluently, keep The service lifecycle handy.
For restart, backoff, and Critical-reboot behaviour, see Keeping services running.
For the commands and their outputs, see Controlling services.
Registry key reference
Peios / Using Peios / Services & jobs
This page is the reference catalog for everything peinit reads from or writes to the registry: the full service-definition schema with types and defaults, and every key peinit touches outside the service definitions. For the meaning of each item, follow the link in its row; this page is for looking up a type, a default, or a path.
Registry value types #
Service definition fields map onto registry value types as follows:
| Schema type | Registry type | Is |
|---|---|---|
| string | REG_SZ | A UTF-8 string. |
| multi_string | REG_MULTI_SZ | An ordered list of strings. |
| dword | REG_DWORD | A 32-bit unsigned integer. |
| binary | REG_BINARY | A raw byte sequence. |
| (timestamp) | REG_QWORD | A 64-bit value — used for timer last-run timestamps. |
The service definition schema #
Every service is a key under Machine\System\Services\<name>; these are the values inside it. Only ImagePath is required. Fields are grouped by purpose; for full semantics see Defining a service and the linked pages.
Execution — see The execution environment
| Field | Type | Default | Meaning |
|---|---|---|---|
ImagePath | string | (required) | Absolute path to the service binary. |
Arguments | multi_string | — | Command-line arguments. |
WorkingDirectory | string | / | Working directory (non-empty absolute path). |
TTYPath | string | — | Absolute path to a terminal to attach as the service's stdio and controlling terminal, e.g. /dev/console or /dev/tty1. Absent or empty means the daemon default (/dev/null stdin, captured output). Attaching a terminal suppresses log capture. |
Environment | multi_string | — | KEY=VALUE pairs added to the environment. |
RuntimeDirectories | multi_string | — | Directories created directly under /run just before the main process starts, each secured for SYSTEM, Administrators, and the service's own SID. |
LimitNOFILE | dword | — | RLIMIT_NOFILE. |
LimitCORE | dword | — | RLIMIT_CORE, in bytes. |
Type and readiness — see Simple and Oneshot services
| Field | Type | Default | Meaning |
|---|---|---|---|
Type | dword | 0 (Simple) | 0 = Simple, 1 = Oneshot. |
Readiness | dword | 0 (Notify) | 0 = Notify (READY=1), 1 = Alive. Ignored for Oneshot. |
RemainAfterExit | dword | 0 | Oneshot only — stay Completed after a successful exit. |
SuccessExitCodes | multi_string | — | Non-zero exit codes treated as success (each 0–255). |
Activation — see Triggers and timers
| Field | Type | Default | Meaning |
|---|---|---|---|
Triggers | multi_string | — | boot and/or timer:<schedule>. Absent = demand-only. |
Disabled | dword | 0 | If 1, triggers must not activate the service. |
SafeMode | dword | 0 | If 1, attempt to start in Safe mode. Critical implies SafeMode. |
Conditions | multi_string | — | Start-time checks; a failure skips the service. |
Asserts | multi_string | — | Start-time checks; a failure fails the service. |
PreStartCheckTimeout | dword | 5 | Seconds allowed for the helper that evaluates filesystem Conditions/Asserts; if it overruns, it is killed and the check counts as not satisfied (a Condition skips, an Assert fails). |
TimerPersistent | dword | 1 | Catch up a missed timer run after a reboot. |
TimerJitter | dword | 0 | Maximum random delay (seconds) added to each firing. |
Identity — see Service identity and privileges
| Field | Type | Default | Meaning |
|---|---|---|---|
Identity | string | LocalService | Principal name or SID for the service token. |
RequiredPrivileges | multi_string | — | Privilege allow-list; all others are removed from the token. |
HookIdentity | string | (service's Identity) | Identity for ExecStartPre/ExecStartPost. |
Dependencies — see Dependencies and ordering
| Field | Type | Default | Meaning |
|---|---|---|---|
Requires | multi_string | — | Hard dependencies. |
Wants | multi_string | — | Soft dependencies. |
BindsTo | multi_string | — | Runtime coupling. |
Conflicts | multi_string | — | Mutual exclusion. |
OnFailure | string | — | Service to start when this one enters Failed. |
Supervision and health — see Keeping services running
| Field | Type | Default | Meaning |
|---|---|---|---|
ErrorControl | dword | 0 (Normal) | 0 = Normal, 1 = Critical (sync + reboot on irrecoverable failure). |
RestartPolicy | dword | 1 (OnFailure) | 0 = Never, 1 = OnFailure, 2 = Always. |
RestartMaxRetries | dword | 5 | Consecutive restarts before Failed. |
RestartWindow | dword | 120 | Seconds of Active health that resets the restart counter. |
RestartDelay | dword | 1 | Backoff base (doubles per failure, capped at 60). |
HealthCheck | string | — | Periodic health-check command. |
HealthCheckInterval | dword | 30 | Seconds between health checks. |
HealthCheckTimeout | dword | 5 | Seconds before a check is killed and failed. |
HealthCheckRetries | dword | 3 | Consecutive failures before unhealthy. |
WatchdogTimeout | dword | 0 | Seconds between expected WATCHDOG=1 pings; 0 disables. |
Transition phases — see The service lifecycle and Controlling services
| Field | Type | Default | Meaning |
|---|---|---|---|
ExecStartPre | multi_string | — | Commands run before the binary (sequential; any failure aborts start). |
ExecStartPost | multi_string | — | Commands run after readiness / successful exit (failure logged, not fatal). |
ExecReload | string | — (SIGHUP) | Reload command, or signal:<NAME>. |
StartTimeout | dword | 30 | Seconds for the whole start sequence. |
StopTimeout | dword | 10 | Seconds between SIGTERM and SIGKILL. |
Notify, fds, security, metadata
| Field | Type | Default | Meaning |
|---|---|---|---|
NotifyAccess | dword | 0 (Main) | Who may send sd_notify (only Main is supported). |
FdStoreMax | dword | 0 | Max fds in the fd store; 0 disables it. |
ServiceSecurity | binary | inherit parent | Descriptor controlling who may manage the service. |
DisplayName | string | — | Human-readable name for status display. |
Description | string | — | Description of the service. |
Service definition keys #
| Key | Type | Purpose | See |
|---|---|---|---|
Machine\System\Services\ | (parent key) | Parent of all service definitions; each child key is a service. | Defining a service |
Machine\System\Services\SchemaVersion | dword | Schema version guard (currently 1). | Defining a service |
Machine\System\Services\<name>\LastTimerRun | REG_QWORD | Last-run timestamp for a single-trigger persistent timer. Written by peinit. | Triggers and timers |
Machine\System\Services\<name>\TimerState\ | (subkey) | Per-trigger last-run timestamps for multi-trigger services. Each value is named by the percent-encoded schedule string and holds a REG_QWORD. | Triggers and timers |
Boot configuration #
Under Machine\System\Boot\:
| Key | Type | Default | Purpose | See |
|---|---|---|---|---|
MaxParallelStarts | dword (> 0) | 10 | Maximum services starting concurrently. Missing → default; zero/invalid → Recovery. | Boot and boot modes |
BootSuccessGrace | dword | 30 | Seconds a Critical service must hold a satisfying state before boot is successful. | Boot and boot modes |
ShutdownTimeout | dword | 90 | Maximum seconds for the whole graceful shutdown. | Shutdown |
SettleTimeout | dword | 5 | Seconds to wait for the boot set to settle before starting boot:settled services anyway. | Triggers and timers |
PostKillTimeout | dword | 5 | Seconds peinit waits for a service cgroup to drain after SIGKILL before treating it as stuck. ShutdownTimeout bounds the shutdown as a whole; this bounds one service's last stage of it. | Shutdown |
Operational parameters #
Under Machine\System\Init\:
| Key | Type | Default | Purpose | See |
|---|---|---|---|---|
ControlSecurity | binary | SYSTEM full; Administrators shutdown + reload-config | Descriptor for system-level control operations. | Who can manage a service |
MaxControlConnections | dword | 32 | Maximum concurrent control-socket connections. | Controlling services |
MaxRequestSize | dword | 65536 | Maximum control-socket request size (bytes). | Controlling services |
ConnectionTimeout | dword | 30 | Seconds before an idle control connection is closed. | Controlling services |
MaxLogLineLength | dword | 8192 | Maximum bytes per service output line before truncation. | Service output and logging |
MaxLogBufferPerService | dword | 65536 | Maximum bytes buffered per service pipe before back-pressure. | Service output and logging |
LogReadBytesPerEvent | dword | 16384 | Bytes drained from a service's output pipe per readable event. Larger favours throughput on chatty services, smaller favours fairness between them. | Service output and logging |
PreEventdBuffer | dword | 1048576 | Total bytes of service output held in memory before eventd is up to receive it. Overflow drops the oldest output; it never blocks a service. | Service output and logging |
EnvVars\ | (parent key) | empty | Default environment variables injected into Phase 2 services (value name = variable, REG_SZ data = value). The descriptor here is security-critical. | The execution environment |
ProvisionedPaths\ | (parent key) | empty | Boot-time path provisioning: each child key names one directory or file that peinit creates and secures after registryd starts and before Phase 2 — the registry-backed equivalent of tmpfiles.d. | Boot and boot modes |
ProvisionedPaths\<name>\Kind | string | (required) | directory or file — what to create at Path. | Boot and boot modes |
ProvisionedPaths\<name>\Path | string | (required) | Absolute path to create or verify. | Boot and boot modes |
ProvisionedPaths\<name>\Security | binary | SYSTEM + Administrators full, users read/traverse | Peios file security descriptor to apply to the object. | Boot and boot modes |
ProvisionedPaths\<name>\Required | dword | 0 | If 1, a failure to provision this entry sends boot to Recovery mode before Phase 2 starts. | Boot and boot modes |
Keys peinit reads from other subsystems #
| Key | Type | Purpose | See |
|---|---|---|---|
Machine\System\eventd\LogSocketPath | string | Path of eventd's log datagram socket, where peinit forwards service output. | Service output and logging |
Where to start #
For how these fields combine into a working definition and when changes take effect, read Defining a service.
To look up the meaning, valid range, and effect-timing of any key on a live system, use regman.
Mount policies
Peios / Using Peios / Mount policies
A mount policy is the per-superblock setting that controls how FACS treats a filesystem. The policy tells the kernel: does FACS apply here at all? When a file has no SD, what should happen? When the kernel synthesises a default SD because the file doesn't have one, where does the template come from? These questions are uniform within a filesystem — every file on a single mount gets the same treatment — and the answer is the mount policy.
There are four policy classes in v0.20. Three are FACS-managed (the kernel applies KACS access control); one is unmanaged (FACS doesn't apply at all). The class is decided when the filesystem is mounted, and changes apply lazily — existing handles are unaffected, but the next access against any file on the mount sees the new policy.
This page introduces the four classes at a glance. Policy classes covers each in detail with the missing-SD and corrupt-SD rules; SD storage by filesystem covers how each filesystem type physically stores the SD; Managing mounts covers the syscalls and the generation counter for cache invalidation.
Why mount policies exist #
A FACS-managed file needs a security descriptor. The DACL on it is the access decision input. The kernel needs to be able to look at any FACS-managed file and find an SD.
But not every filesystem can store an SD on every file. ext4 stores SDs in xattrs, but only up to 4 KB without special features; XFS supports xattrs natively up to 64 KB; tmpfs has no persistent storage at all; FAT and exFAT have no xattr support; remote filesystems like NFS may not surface SDs to the client. The kernel needs different behaviours for these cases.
That's what mount policies provide. The policy is the kernel's per-filesystem answer to "what to do when a file does not have an SD I can read":
- For some filesystems, refuse access (the file should have an SD and doesn't, so something is wrong).
- For some, synthesise an SD on the fly in memory (and don't try to write it back).
- For some, synthesise an SD and try to write it back so future accesses don't need to re-synthesise.
- For some (the kernel's own pseudo-filesystems), don't apply FACS at all.
Each of these is one of the four classes. The class is a property of the mount, not the file.
The four classes at a glance #
| Class | What it does | Typical use |
|---|---|---|
facs_deny_missing | FACS-managed. A file with no SD is unreachable — every access fails. | System mounts (root, /home, /var) where every file should have an SD. |
facs_synthesize_ephemeral | FACS-managed. A missing SD is synthesised in memory but not written back. The next access re-synthesises. | Removable media, FAT/exFAT, NFS client mounts — filesystems that may not preserve xattrs cleanly or where modifying SDs is not appropriate. |
facs_synthesize_persistent | FACS-managed. A missing SD is synthesised and immediately written back. Subsequent accesses use the stored SD. | Filesystems being adopted into Peios — a previously-unmanaged filesystem getting an SD on first access. |
unmanaged | FACS does not apply. The kernel uses its own per-operation access rules for files under this mount. | /proc, /sys — kernel pseudo-filesystems with their own access semantics. |
unmanaged is not settable via the public ABI — only the kernel itself sets this class, at boot time, for the pseudo-filesystems it manages. The other three are administratively settable.
A mounted filesystem has exactly one of these classes at any time. Changing the class is a single operation that affects every file on the mount going forward (subject to the lazy-invalidation behaviour).
Per-superblock, not per-path #
A mount policy is per-superblock, not per-path or per-bind-mount. All paths visible through the same underlying filesystem instance share the same policy.
The implications:
- A bind mount of
/etc/peiosto/old/etc/peiosdoes not let you set different policies for the two paths. They are the same superblock; one policy. - A read-only bind mount and the underlying read-write mount of the same filesystem share the policy.
- Two separate mounts of the same filesystem (uncommon but possible in some namespacing setups) each have their own superblock instance and can have different policies.
The per-superblock rule keeps mount policy simple. The alternative — per-mount or per-path — would mean a single inode could be subject to different policies depending on how it was reached, which would be operationally confusing.
What the policy decides #
For a FACS-managed mount (any of the three managed classes), the policy decides three things:
- What happens when an inode has no SD attached. Different classes handle this differently — deny, synthesise-in-memory, or synthesise-and-persist.
- The mount-level SD template. Each FACS-managed mount can carry a default SD template, used when synthesising an SD for a file that has no parent-derivable SD (root of the filesystem, typically).
- Whether mutations to synthesised SDs are persisted. Ephemeral synthesises in memory only; persistent writes back to the filesystem.
For the unmanaged class, the policy decides one thing: FACS doesn't apply. Files under this mount are governed by whatever per-operation rules the kernel has for that pseudo-filesystem.
What the policy does not decide #
A few clarifications:
- The policy does not change what filesystem operations are available. Read, write, mmap, all the usual operations work normally on every class. The policy affects how the access check runs, not what operations exist.
- The policy does not control persistence of file data. A file's contents are persisted (or not) by the underlying filesystem; the policy is only about SD persistence.
- The policy does not affect already-open handles. The handle model caches access masks on fds; changing the policy does not retroactively change cached masks. Future opens see the new policy.
- The policy does not propagate inheritably. Changing a parent's mount policy doesn't affect mounted-elsewhere child directories — each mount carries its own policy on its own superblock.
Where to start #
If you want each class in detail — what facs_deny_missing actually does at runtime, how synthesize_ephemeral works for FAT, what happens when a synthesised SD turns out to be problematic — read Policy classes.
If you want to know how each filesystem stores SDs — ext4's ea_inode, XFS's native xattr support, NTFS via system.ntfs_security, the no-xattr-support filesystems — read SD storage by filesystem.
If you want the operational story — kacs_set_mount_policy, kacs_get_mount_policy, the generation counter that makes invalidation lazy, why mount policy changes don't walk the filesystem — read Managing mounts.
The commands #
The concept pages above describe the model; three command-line tools put it to work. mount attaches a filesystem to the mount tree and is where a KACS mount policy (and its optional SD template) is chosen at attach time — the one place the policy classes above meet a command line. umount detaches a filesystem again, by mount point or by source, with lazy, forced, recursive and all-targets variants. lsblk lists the block devices available to mount, with their filesystems and, like ls -l, their SD-derived owner and mode. Use these when you want the operational reference for a specific flag rather than the model behind it.
Policy classes
Peios / Using Peios / Mount policies
The four policy classes are the answer to one question: when FACS needs an SD for a file and the filesystem doesn't have one, what should happen?
The three managed classes — facs_deny_missing, facs_synthesize_ephemeral, facs_synthesize_persistent — answer the question in three different ways. The fourth, unmanaged, answers it by stepping out of the question entirely: FACS doesn't apply, so the question doesn't arise.
This page covers each class in detail, the synthesis chain that produces an SD when one is needed, and the universal corrupt-SD rule.
facs_deny_missing #
A FACS-managed mount where missing SDs are treated as an error. Every file on a facs_deny_missing mount must have a valid SD; one that does not is unreachable.
The runtime behaviour:
- The kernel reads the file's SD from the filesystem (typically an xattr).
- If no SD is present, the access fails. The kernel returns
-EACCESfor any operation that would require an SD-based access check. - The file is effectively unreadable, unwritable, undeletable, unmodifiable. Every access fails because there is no policy to evaluate against.
This is the strict mode. It is appropriate for filesystems where every file should have been provisioned with an SD — system mounts, application storage, anywhere the operator has set up the filesystem deliberately.
The defaults for Peios system mounts (the root filesystem, /home, /var) are facs_deny_missing. The image-build process ensures every file in the base image has an SD; subsequent file creation always inherits an SD from the parent. There is no path by which a file without an SD legitimately ends up on these filesystems.
If somehow a file does end up without an SD — a backup-restore tool that skipped xattrs, a misbehaving filesystem driver — that file is unreachable. The fix is to give it an SD (kacs_set_sd, requires WRITE_DAC, which the caller may not have if they cannot read the file).
Practical upshot: a facs_deny_missing mount is strict. Files without SDs stay unreachable until given one. This is the right setting for filesystems where you trust the provisioning.
facs_synthesize_ephemeral #
A FACS-managed mount where missing SDs are synthesised in memory, but not written back to the filesystem. The synthesised SD is used for the access check; it does not become part of the file's persistent state.
The runtime behaviour:
- The kernel reads the file's SD from the filesystem.
- If no SD is present, the kernel synthesises one using the synthesis chain (covered below).
- The synthesised SD is used for the access check.
- The synthesised SD is not written back to the filesystem. It exists only in the kernel's cache for the duration this inode is in memory.
- On a subsequent open of the same file (after the inode has been evicted from cache, say), the SD is synthesised again. The same inputs produce the same output, so the same SD comes out — but the synthesis is repeated.
This class is appropriate for filesystems where you can't or don't want to write SDs:
- Removable media (USB drives, optical media). You don't want to modify the media just because you read a file on it.
- FAT and exFAT. These filesystems have no xattr support; the SD has nowhere to go even if you wanted to write it.
- NFS client mounts. The actual file lives on a remote server; modifying its SD via xattr would have unpredictable effects.
- tmpfs and devtmpfs. Per-instance pseudo-filesystems with no real persistence.
The synthesis-only-in-memory pattern lets FACS apply access control to these filesystems without changing their on-disk content. The trade-off is that the cost of synthesising is paid every time the inode is cold.
facs_synthesize_persistent #
A FACS-managed mount where missing SDs are synthesised and written back. Each file is synthesised once; from then on it has a real SD.
The runtime behaviour:
- The kernel reads the file's SD from the filesystem.
- If no SD is present, the kernel synthesises one using the synthesis chain.
- The synthesised SD is used for the access check — immediately, from the kernel's cache.
- The synthesised SD is also written back to the filesystem (typically as the SD xattr). The file now has a persistent SD.
- On a subsequent open, the SD is read from storage — no re-synthesis needed.
The write-back in step 4 does not happen during the access — it is deferred until just after the triggering operation finishes (when the kernel can safely write without holding the locks the access is using). In practice the SD is on disk by the time the syscall that first touched the file returns to userspace, so for an observer it is effectively immediate.
Because the synthesised SD is a deterministic function of its inputs (the parent's SD, or the mount template), the on-disk copy is a cache of a value the kernel can always recompute. If the write-back is interrupted — the inode is evicted first, or the process exits in the gap — nothing is lost: the next access re-synthesises the identical SD and tries the write-back again. A file is never left with the wrong SD, only occasionally with the SD still in memory rather than on disk. (A mount-template change in that gap simply discards the pending SD and re-synthesises against the new template, so a superseded SD is never frozen onto the disk.)
This class is for filesystems being adopted into Peios. A previously-unmanaged filesystem (say, an ext4 volume from a Linux system without KACS) can be mounted with facs_synthesize_persistent; every file accessed gets an SD on first access; over time, every file ends up with an SD.
The use case: migrating an existing filesystem to KACS-managed without a single-pass conversion tool. The synthesis-on-access pattern lets the conversion happen incrementally as files are touched. After enough time, every regularly-accessed file has been adopted; rarely-touched files get adopted the next time they are read.
After all files have SDs, the mount can be switched to facs_deny_missing for strict mode. The transition is a single kacs_set_mount_policy call; existing SDs are unaffected, and any straggler files without SDs become unreachable (which is the desired effect of switching to strict).
unmanaged #
The unmanaged class is the special case for filesystems FACS shouldn't apply to. The kernel uses its own per-operation access rules for files under this mount; no SD-based access check runs at FACS's level.
Specifically:
/procisunmanaged. Access to/proc/<pid>/*files is governed by the process-level checks (process SD + PIP, per the two-check rule), not by FACS./sysisunmanaged. Writes are restricted toBUILTIN\AdministratorsandSYSTEMby hardcoded rule./sys/kernel/security/kacs/*has explicit SDs set by the kernel; reading these uses the kernel's own logic.
The unmanaged class cannot be set via the public ABI. The kacs_set_mount_policy syscall rejects attempts to set this class with -EINVAL. Only the kernel itself sets this class — at boot, for the pseudo-filesystems it manages.
The reason for restricting this: making a regular filesystem unmanaged would mean FACS has no say over it at all, which would be a meaningful operational decision but also a security-relevant one. The kernel reserves the class for its own use.
If you mount a regular filesystem and don't want FACS to apply, the closest you can get is facs_synthesize_ephemeral with a permissive mount template — files get a permissive synthesised SD that effectively grants access. This is not the same as no FACS, but it is the closest path available through the public ABI.
The synthesis chain #
For the two synthesising classes, when the kernel needs to produce an SD for a file with none, it uses a chain of sources. The first source that yields a usable SD wins.
flowchart LR
A["File has no SD"] --> B["Inherit from parent directory"]
B -->|parent has inheritable ACEs| F["Use computed SD"]
B -->|parent has none / no parent| C["Mount-level SD template"]
C -->|template is set| F
C -->|no template| D["Hardcoded fallback SD"]
D --> F
In order:
- Parent directory inheritance. The kernel reads the parent directory's SD and computes what a newly-created child would inherit (per the Inheritance rules). If this produces a usable SD (with inheritable ACEs from the parent), that SD is used.
- Mount-level SD template. Each FACS-managed mount can carry a default SD template — set via
kacs_set_mount_policyalong with the policy class. The template is a complete self-relative SD (max 64 KB). If the parent did not yield an SD, the template is used. - Hardcoded fallback. If neither the parent nor the template produces an SD, the kernel uses a hardcoded fallback:
GENERIC_ALLto SYSTEM andBUILTIN\Administrators;GENERIC_READ | GENERIC_EXECUTEto Everyone. The owner is set to SYSTEM, group to SYSTEM.
The fallback is conservative: administrators get full control, others get read-and-execute. It is what every filesystem mounted with facs_synthesize_* and no specific configuration falls back to, and what every root-of-mount file ends up with on a freshly-installed Peios system.
For most mounts, the fallback is the safety net — typical files have parents with inheritable ACEs, and synthesis lands on step 1. The template is used for files at the root of the mount (where there's no parent on this filesystem) or in unusual cases where parent-inheritance doesn't apply.
Corrupt SD handling #
A universal rule across all FACS-managed classes: a corrupt SD is treated as a denial. If the SD on a file exists but fails structural validation — bad header, malformed ACL, invalid SID, exceeds the size limit, anything that prevents the parser from making sense of it — the access check fails with -EACCES.
The kernel does not fall back to synthesis when an existing SD is corrupt. The synthesis path is for files with no SD; a file with a broken SD is different. The kernel:
- Detects the corruption when reading the SD.
- Returns
-EACCESfor the access. - Emits an audit event (one per inode per cache population — the same corrupt SD encountered repeatedly during one mount's lifetime produces one audit event for that inode, not one per access).
The corruption audit event is part of the audit stream and useful for diagnostics. A misbehaving backup-restore tool that produced corrupt SDs on a restored set of files generates a flurry of these events when the files are first accessed.
The corrupt-SD rule is the same across all three FACS-managed classes. facs_deny_missing denies missing; facs_synthesize_* synthesises missing; all three deny corrupt. The "missing" and "corrupt" cases are different — missing is "no SD was attached" and is recoverable through synthesis or operator intervention; corrupt is "the SD that was attached is broken" and requires either fixing the SD or accepting denial.
Class transitions #
Changing a mount's policy class is allowed (subject to access rules covered in Managing mounts). The interesting transitions:
facs_synthesize_persistent→facs_deny_missing. Common during migration. As files are adopted bysynthesize_persistent, they accumulate SDs. Once enough have been adopted, switch todeny_missingto enforce strictness. Any file still missing an SD becomes unreachable; an administrator can either accept that or set SDs on the holdouts.facs_synthesize_ephemeral→facs_synthesize_persistent. Less common. Would convert an ephemeral mount to a persistent one — the next access of any file without a stored SD would write the synthesised SD back. This effectively "snapshots" the synthesis on first access.facs_deny_missing→facs_synthesize_*. Unusual; would relax strictness. The kernel allows it but the operational reasoning is rarely good — synthesis is a recovery mechanism, not an everyday setting.
A class transition is just a policy update; no files are touched at the transition. The new policy applies to future accesses. Existing cached state (synthesised SDs in memory) may need to be re-evaluated — see the generation counter in Managing mounts.
Where to go next #
For how each filesystem physically stores the SD, read SD storage by filesystem.
For the syscalls that set and read a mount's policy, read Managing mounts.
For choosing a policy from the command line at attach time, read mount.
SD storage by filesystem
Peios / Using Peios / Mount policies
A security descriptor is structurally a single piece of metadata that has to live somewhere. Different filesystems have different ways to hold metadata; some have built-in security-attribute support (NTFS), some have generic extended attribute support that fits (ext4, XFS, Btrfs), some have neither (FAT, exFAT). The mount-policy class you choose for a filesystem depends partly on what the filesystem can store and partly on what behaviour you want.
This page covers the storage mechanism each filesystem type uses and the implications.
The canonical xattr #
For filesystems that support extended attributes, Peios stores the SD in the security.peios.sd xattr. The name is in the security.* namespace, which requires privileged access to read or write at the filesystem layer — which doesn't matter for FACS-managed access (FACS denies direct xattr operations on the SD xattr unconditionally; access goes through kacs_get_sd / kacs_set_sd), but does mean the xattr survives operations that respect security xattrs.
For NTFS, the xattr name is system.ntfs_security — the native NTFS security attribute, accessed through the same xattr interface. Using the NTFS-native name means SDs round-trip cleanly between Peios and other operating systems that read NTFS.
The choice of xattr name is filesystem-specific:
| Filesystem | xattr name |
|---|---|
| ext4, XFS, Btrfs, tmpfs, others with generic xattr support | security.peios.sd |
NTFS (via the ntfs3 driver) | system.ntfs_security |
For other filesystems (with no xattr support or no convention), the SD is held in memory and re-synthesised on each cold open. See the per-filesystem sections below.
ext4 #
ext4 supports xattrs natively. SDs up to about 4 KB fit in the inline xattr space; larger SDs require the ea_inode feature.
ea_inode is an ext4 feature that lets a single xattr value spill into a dedicated inode rather than being limited to the size of the inode's inline xattr area. With ea_inode enabled at filesystem creation time (or enabled later via tune2fs), an SD can be up to the standard 65,535-byte SD limit.
mke2fs on Peios enables ea_inode by default for ext4, and raises the inode size to 512 bytes so that a typical SD fits inline in the first place — see mke2fs. Real-world SDs rarely exceed the inline xattr space, but pathological cases (deeply-nested ACEs, lots of inherited ACEs from a complex parent) can push past 4 KB; ea_inode accommodates them.
If an ext4 filesystem without ea_inode is mounted on Peios and a file has an SD larger than its inline xattr space could hold, the filesystem's xattr write would fail. The kernel handles this by either:
- Refusing the
kacs_set_sdcall with an error indicating the filesystem cannot hold the SD. - Or, in the
facs_synthesize_persistentcase, refusing the write-back and treating the file as if it has no SD (re-synthesising next time).
The cleanest fix is to enable ea_inode. For everyday use, the inline xattr space is sufficient.
XFS #
XFS supports xattrs natively, up to 64 KB per attribute by default. This is comfortably more than the SD size limit (65,535 bytes), so SDs fit without any special configuration.
XFS is a fine choice for FACS-managed filesystems. No ea_inode-style consideration is needed; SDs just work.
Btrfs #
Btrfs supports xattrs natively. Small xattrs are stored inline; larger ones get their own extent. SDs of any practical size fit without special handling.
Btrfs's snapshot and clone behaviour is interesting for SDs: a Btrfs snapshot of a directory includes the SDs of every file within. A snapshot is a point-in-time view; the SDs in the snapshot reflect what they were when the snapshot was taken. If the original files' SDs are subsequently modified, the snapshot still has the old SDs. This is the expected behaviour but worth noting for backup/snapshot workflows.
tmpfs and devtmpfs #
tmpfs is an in-memory filesystem. It supports xattrs, but everything it stores lives in RAM and is lost on unmount or reboot. SDs on tmpfs are present while the filesystem is mounted; they vanish when it is unmounted.
tmpfs is typically mounted with facs_synthesize_ephemeral. The synthesis happens in memory anyway (the tmpfs storage is memory), so there's no operational difference between "store an SD in the tmpfs xattr" and "synthesise an SD into the kernel's per-inode cache".
devtmpfs is the kernel-managed filesystem for device nodes. SDs on devtmpfs are applied by udev rules, not by FACS-driven synthesis. The udev daemon (or its Peios equivalent) sets the SD on each device node as the node is created. This is a different model from the synthesis-based pattern other filesystems use; it works because the device-node population is controlled centrally and the SDs can be set deterministically.
devtmpfs uses the facs_synthesize_ephemeral class, but in practice the synthesis path is rarely hit because udev provides the SDs.
NTFS — round-trip via ntfs3 #
NTFS is the Windows-native filesystem. Peios mounts it through the ntfs3 kernel driver, which exposes the NTFS security attribute via the standard xattr interface under the name system.ntfs_security.
The implications:
- SDs written by Peios to an NTFS volume use the same on-disk format as Windows. A Windows system reading the same volume sees the SD natively.
- SDs written by Windows to an NTFS volume are readable by Peios. Round-tripping a volume between the two operating systems preserves SDs.
This is the "binary-compatible" property the SD format gives. The wire format is the same; the filesystem driver translates between the xattr interface and the on-disk security attribute.
NTFS volumes are typically mounted facs_synthesize_ephemeral rather than facs_deny_missing. The reasoning: an NTFS volume from Windows may have files without Peios-recognised SDs (the SDs are Windows-native and may use principals that don't exist on the Peios system). Ephemeral synthesis lets such files be accessible without modifying the volume's stored SDs.
FAT and exFAT #
FAT and exFAT have no xattr support at all. There is no place to store an SD; the on-disk format simply doesn't have the metadata channel.
FACS handles this by using facs_synthesize_ephemeral for FAT/exFAT mounts. Every file gets a synthesised SD in memory; no SD is ever written back. The synthesised SD applies for the file's time in the inode cache; when the file is evicted, the SD is gone and will be re-synthesised on next access.
This means FAT/exFAT files cannot be given persistent KACS-style permissions. The synthesised SD is the same every time (assuming the same inputs — parent SD, mount template), so the access decision is deterministic, but there is no way to customise per-file.
For most FAT use cases this is fine — FAT is typically used for removable media or boot partitions where uniform-permissions semantics is acceptable. The mount-level SD template can be configured to grant whatever access pattern is appropriate for the mount as a whole.
NFS — synthesise locally, enforce remotely #
NFS client mounts are a unique case. The actual files live on a remote server; the local filesystem driver is a network protocol implementation, not a real filesystem.
The mount class is facs_synthesize_ephemeral. FACS synthesises a local SD per the synthesis chain (typically yielding a sensible default from the mount template) and uses it for local access control. The local FACS check decides whether to forward the operation to the server.
If the local check passes, the operation goes to the server. The server has its own access control (potentially also Peios with FACS, potentially another OS with different rules); the server's access control decides whether the operation actually proceeds. If the server denies, the operation fails with whatever error the protocol returns (typically -EACCES or -EIO).
This is "dual authority" — both client and server have a say. The client's denial is local-final; the server's denial is remote-final. There is no single source of truth for the access decision.
The implications were covered in Special cases under "NFS — dual authority": don't trust local FACS results for security, expect I/O errors from server-side denial, the local synthesised SD is not the server's actual SD.
/proc and /sys #
/proc and /sys are kernel pseudo-filesystems. Their mount-policy class is unmanaged — set by the kernel at boot, not changeable via the public ABI.
/proc doesn't have an SD per-file in the FACS sense. Access to /proc/<pid>/* is gated by the per-process rules (process SD + PIP, from the two-check rule). The kernel implements these checks directly when serving /proc file operations.
/sys similarly has its own per-file rules. The /sys/kernel/security/kacs/* entries have explicit SDs the kernel maintains; other /sys files have hardcoded rules ("writes restricted to BUILTIN\Administrators and SYSTEM").
The unmanaged class is what tells FACS to not interfere with these. The kernel knows what it is doing with its own pseudo-filesystems; FACS stays out of the way.
Stacked filesystems — overlayfs and StrataFS #
A stacking filesystem presents files that physically live somewhere else. overlayfs merges a read-only lower layer with a writable upper one; StrataFS composes several directories into one view. Neither stores a security descriptor of its own, and neither needs to: the descriptor belongs to the file, and the file is on the layer underneath.
So a stack forwards. When the kernel needs the SD of a file in the merged view, it asks the layer that actually holds it, and what comes back is that layer's effective descriptor — which is to say, whatever an access check on the underlying file would have used. If the layer underneath stores an SD, the stack sees the stored one. If the layer underneath synthesises, the stack sees the synthesised one. Synthesis composes upward through the stack; it is not a private arrangement between the kernel and the bottom layer.
That is what makes the common live-boot arrangement work:
overlay <- the merged root, facs_deny_missing
upper: tmpfs <- writable scratch, stores real SDs
lower: squashfs <- read-only image with no SDs, facs_synthesize_ephemeral
The overlay stores nothing and can still be facs_deny_missing, because neither layer beneath it ever answers "missing": the tmpfs has stored descriptors, and the squashfs synthesises one for every inode.
Two consequences worth holding onto:
The stack's own policy class governs only what happens when every layer beneath it has nothing to offer. Pick it for that case. Over a synthesising lower, facs_deny_missing is the strict-and-correct choice; over an unmanaged lower it would lock the whole view.
A copy-up does not carry the lower's descriptor up with it. When overlayfs copies a file from the lower layer to the upper to make it writable, it creates a genuinely new inode in the upper, and that inode gets its descriptor by ordinary inheritance from its parent in the upper — not by duplicating the lower's. This is deliberate: security.peios.sd is not userspace-writable (SD mutation is kacs_set_sd's job), so a verbatim copy could not be performed even in principle, and the inherited answer is the right one anyway. Every other extended attribute is copied up normally.
Summary #
| Filesystem | SD storage | Typical mount class |
|---|---|---|
| ext4 | security.peios.sd xattr; ea_inode for SDs > 4 KB | facs_deny_missing (for system mounts) |
| XFS | security.peios.sd xattr, native large support | facs_deny_missing |
| Btrfs | security.peios.sd xattr, native | facs_deny_missing |
| tmpfs | security.peios.sd xattr in memory | facs_synthesize_ephemeral |
| devtmpfs | xattr in memory, populated by udev | facs_synthesize_ephemeral |
| NTFS (ntfs3) | system.ntfs_security xattr, NTFS-native | facs_synthesize_ephemeral (typical) |
| FAT / exFAT | No SD storage; in-memory only | facs_synthesize_ephemeral |
| NFS client | No client-side storage; synthesise locally | facs_synthesize_ephemeral |
| squashfs | No SD storage unless the image was built with one | facs_synthesize_ephemeral |
| overlayfs | None of its own — reads the real layer | facs_deny_missing |
| StrataFS | None of its own — reads the provider | facs_deny_missing, not settable |
| /proc | n/a — no FACS | unmanaged |
| /sys | n/a — kernel-managed SDs | unmanaged |
The pattern: real on-disk filesystems with xattr support get one of the facs_* classes depending on policy needs. Pseudo-filesystems and the kernel's own filesystems are unmanaged. Filesystems without xattr support default to ephemeral synthesis as the only viable mode. Stacking filesystems store nothing and defer to the layer beneath.
Where to go next #
For what each policy class does with a missing SD, read Policy classes.
For setting and reading a mount's policy at runtime, read Managing mounts.
For the structure of the SD being stored, read Security descriptors.
Managing mounts
Peios / Using Peios / Mount policies
A mount's policy is set via a syscall, read via a syscall, and otherwise sits as kernel-internal state on the filesystem's superblock. Mount-policy changes are administrative — they require SeTcbPrivilege — and they take effect lazily, without the kernel walking the filesystem to update anything.
This page covers the two syscalls (kacs_set_mount_policy, kacs_get_mount_policy), the generation counter that makes lazy invalidation work, and what does and does not happen at a policy change.
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.
SeTcbPrivilegeis required. - 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.
Privilege requirement #
The reasoning: changing a mount's policy is an administrative decision that affects the entire filesystem's access semantics. It belongs in the TCB tier, not in the regular-administrator tier. An ordinary administrator who wants to change a mount's policy goes through a tool that itself has the privilege.
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.
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.
SeTcbPrivilegeis required to read. - Writes the policy class, generation, and template to
args(template only if a buffer was provided). - Returns 0.
Like the write syscall, this is SeTcbPrivilege-gated. The mount policy is system-level state, exposed only to TCB-tier callers.
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.
The generation counter #
A subtle but important feature: the kernel maintains a monotonic generation counter per superblock. Each successful kacs_set_mount_policy (or template change) increments it.
The purpose is lazy invalidation.
Without the counter, a policy change would either need to immediately propagate to every cached SD on the filesystem (expensive — could be millions of inodes) or accept that cached SDs from before the change remain valid (incorrect — the new policy might say different things).
With the counter, the kernel can do something smarter:
- Each in-kernel cached SD or synthesised entry records the generation it was derived from.
- At access time, the kernel compares the cached entry's generation against the superblock's current generation.
- If they match, the cached entry is current; use it.
- If they differ, the cached entry is stale; discard it and re-derive.
The kernel does not walk the filesystem at the policy change. The walk happens implicitly, one inode at a time, as accesses arrive. An access that arrives 30 seconds after the policy change is the first to invalidate that specific inode's cached state; the access pays a small cost to re-derive, but every subsequent access uses the new cached state.
This is why mount policy changes are cheap to apply. The expensive work (walking inodes) never happens; what happens instead is per-inode re-derivation on first access after the change.
What the generation counter affects #
The counter affects in-kernel cached state derived from the mount policy:
- Missing-SD synthesised entries. If a file had a missing SD and was synthesised on-the-fly, the synthesised SD is cached. A policy change invalidates this cache; the next access re-synthesises.
- Ephemeral-synthesis caches for
facs_synthesize_ephemeralmounts. Same. - Template-derived information — the kernel's representation of the mount template is regenerated on next access.
The counter does not affect:
- On-disk SDs. A file that has a stored SD continues to have that SD. The policy change doesn't rewrite stored SDs.
- Already-open file descriptors. The cached granted mask on an fd is independent of the mount-policy generation; it's the result of the access check at open time. Policy changes after open don't affect open fds. (This is the standard check-at-open model.)
- Inodes not currently in cache. They have nothing to invalidate; the next access just goes through the normal path with the current policy.
Reading the counter #
The generation counter is exposed in the output of kacs_get_mount_policy. A management tool can read the counter to know when policy was last changed, or compare against a previously-read value to detect external changes.
The counter is per-superblock; different filesystems have unrelated counter values. The counter starts at some implementation-defined value at mount time and increases monotonically with each policy change.
What happens at a policy change #
To summarise, when kacs_set_mount_policy succeeds:
- The superblock's policy class field is updated.
- The superblock's template SD is updated.
- The superblock's generation counter is incremented.
- Open file descriptors against files on this mount are unaffected (cached granted masks intact).
- Cached in-memory state derived from the old generation will be invalidated on first access after the change.
- Files with stored SDs continue to have those SDs.
- The filesystem itself is not walked, not modified, not reorganised.
The change is essentially a small superblock update plus a lazy invalidation marker. The work happens at future accesses, distributed across actual usage.
What policy changes do not do #
A few clarifications:
- They don't rewrite SDs on disk. A change from
facs_synthesize_ephemeraltofacs_synthesize_persistentdoesn't go back and write synthesised SDs to disk. Files that were synthesised under the old policy continue to have no on-disk SD; the next access re-synthesises (now writes back, per the new persistent policy). - They don't close open files. Processes with handles to files on the mount continue to have those handles. The cached granted masks are unchanged.
- They don't propagate across mounts. A policy change on one mount doesn't affect any other mount, even if they reference the same underlying device (per the per-superblock rule).
- They don't change the FACS handle model. The new policy applies to future access checks; existing handles use their cached masks per usual.
Use patterns #
A handful of common patterns for managing mount policy:
Boot-time policy. peinit applies the policy class and template for each mounted filesystem at boot. The configuration source is typically registry-based; peinit reads the configuration and calls kacs_set_mount_policy for each mount.
Migration from a non-Peios filesystem. Mount with facs_synthesize_persistent initially. Let usage gradually adopt files into having stored SDs. Once enough have been adopted, switch to facs_deny_missing (or leave it as synthesize_persistent if you want the synthesis-on-missing safety net).
Removable media insertion. When a removable filesystem is mounted (a USB drive, an optical disc), the mount setup chooses facs_synthesize_ephemeral. The volume's contents become reachable without modifying the on-disk metadata; ejection cleanly leaves the media untouched.
Policy uplift on a hardened deployment. A deployment starting with facs_synthesize_ephemeral everywhere (permissive) can migrate to facs_synthesize_persistent (filling in stored SDs) and then to facs_deny_missing (strict mode). Each transition is one kacs_set_mount_policy call per mount.
Errors #
Possible failures from kacs_set_mount_policy:
| Error | Cause |
|---|---|
-EBADF | The fd is invalid. |
-EPERM | The caller does not hold SeTcbPrivilege. |
-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 | The caller does not hold SeTcbPrivilege. |
-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.
See also #
- Policy classes — what each settable class does.
- mount — setting the policy from the command line at attach time.
- Privileges — the privilege model behind the SeTcbPrivilege gate.
mount
Peios / Using Peios / Mount policies
mount attaches a filesystem to the Peios mount tree. With no operands it instead lists what is currently mounted. It is a faithful reworking of the util-linux mount(8) surface for Peios, with two structural differences: Peios has no /etc/fstab and no /etc/mtab, so every mount is described entirely on the command line and live mount state is read from the kernel; and a mount can apply a KACS mount policy to the new filesystem at attach time (see Mount policies).
mount [-t TYPE] [-o OPTIONS] SOURCE TARGET
mount [-o remount,OPTIONS] TARGET
mount --bind|--rbind|--move SOURCE TARGET
mount --make-shared|--make-private|... TARGET
mount [-l] [-t TYPE]
Internally mount uses the fd-based mount API (fsopen / fsconfig / fsmount / move_mount, plus open_tree and mount_setattr), never the classic single-shot mount(2). This matters in one place you can see: when a mount fails, the kernel's fs_context message log is drained and printed as the reason, even without -v.
Operands and argument shapes #
Because there is no fstab to consult, operand resolution is strict:
- No operands, no verb — list mode (see below).
- Two operands —
SOURCE TARGET. - One operand — an error. In util-linux a lone operand is resolved through fstab; Peios has none, so a single operand cannot be turned into a
SOURCE TARGETpair. Supply both, or use--source/--target. - A lone target is valid only for
-o remountand for a standalone propagation change (--make-*), which act on an existing mount point.
--source SRC and --target DIR name the operands explicitly and may be combined with a single positional. --target-prefix DIR prepends DIR/ to the target after it is chosen. Options may be interspersed with operands (mount SRC -o ro TGT), matching util-linux.
Paths, -o key=value values, labels and UUIDs are handled as opaque byte strings, never assumed to be UTF-8; an embedded NUL is a usage error.
Canonicalisation #
By default source and target paths are canonicalised (made absolute, symlinks resolved). -c / --no-canonicalize disables that; X-mount.nocanonicalize[=source|target] is the -o form and can disable it for just one side. The final path handed to the kernel is protected against a TOCTOU symlink swap on its last component.
Operation modes (verbs) #
mount performs one of several operations. The structural verbs — bind, rbind, move, beneath and remount — are mutually exclusive with one another. A propagation change is not a structural verb: it may stand alone on a target, or trail another verb or a new mount in the same command (applied afterwards as one or more mount_setattr steps), and several may be combined.
| Verb | How to request it | What it does |
|---|---|---|
| New mount | mount [-t T] SRC TGT | Attach a fresh instance of the filesystem at SRC onto TGT. |
| Bind | -B / --bind, or -o bind | Make an existing subtree visible at a second location. |
| Recursive bind | -R / --rbind, or -o rbind | Bind a subtree together with every mount underneath it. |
| Move | -M / --move, or -o move | Relocate an existing mount to a new mount point. |
| Move beneath | --beneath SRC TGT | Attach SRC beneath the mount currently at TGT (the kernel enforces several constraints; violations surface as exit 32). |
| Remount | -o remount[,...] TGT | Change the options of an existing mount (see Remounting). |
| Propagation | --make-* / --make-r*, or the -o tokens below | Change how mount/unmount events propagate across a subtree. |
| List | mount / mount -l | Print the current mounts. |
Propagation flags #
Each of these sets the propagation type of the mount at the target. The --make-r* (and r-prefixed -o) forms apply recursively to the whole subtree; the recursive form is all-or-nothing (it either applies to the entire subtree or to none of it).
| Flag | -o token | Recursive flag | Recursive -o token | Meaning |
|---|---|---|---|---|
--make-shared | shared | --make-rshared | rshared | Events propagate to and from peer mounts. |
--make-slave | slave | --make-rslave | rslave | Events propagate in from the master but not back out. |
--make-private | private | --make-rprivate | rprivate | No propagation either way. |
--make-unbindable | unbindable | --make-runbindable | runbindable | Private, and cannot be bind-mounted. |
A freshly created mount, bind or move is private by default unless its destination's parent is shared, in which case it joins that peer group — the standard kernel rule.
Source specification #
| Form | Resolution |
|---|---|
Device path (/dev/sda1) | Used directly. |
-L LABEL / LABEL=, -U UUID / UUID=, PARTLABEL=, PARTUUID= | Resolved to a device via libblkid. No match is a mount failure (exit 32). |
| A directory or regular file | Used directly (a bind source or target; a file may be a bind target). |
A pseudo source (tmpfs, proc, sysfs, none, …) | Passed through; -t is required because it cannot be probed. |
| An image file | Attached through a loop device (see Loop devices). |
Filesystem type #
-t TYPE names the type explicitly. -t auto, or omitting -t entirely, asks libblkid to safely probe the source; an ambiguous or multiply-signed device is refused (exit 32) rather than guessed at. X-mount.auto-fstypes=LIST constrains the probe candidates. Type lists and no<type> negation are not accepted in the mounting path (they remain meaningful only as a listing filter).
The -o option language #
-o takes a comma-separated list of key or key=value tokens and is repeatable (all occurrences are joined). A key="..." double-quoted value protects embedded commas; the key=value split is on the first =. Every token is sorted into one of the following categories.
Per-mount attributes #
Applied to the mount itself. Each accepts its negation; ro/rw additionally accept a =vfs / =fs / =recursive scope qualifier (bare is =vfs, non-recursive; =fs targets the superblock read-only flag; =recursive applies to the whole subtree).
| Option | Effect |
|---|---|
ro / rw | Read-only / read-write. |
suid / nosuid | Honour / ignore set-user-ID bits. |
dev / nodev | Allow / disallow device nodes. |
exec / noexec | Allow / disallow execution. |
atime / noatime | Update / never update access times. |
relatime / norelatime | Relative-atime updates on or off. |
strictatime / nostrictatime | Strict-atime updates on or off. |
diratime / nodiratime | Directory access-time updates on or off. |
nosymfollow | Do not follow symlinks on this mount. There is no positive symfollow token — the attribute is cleared only via a remount mask. |
The atime tokens share a single mode field; the last one specified wins.
Superblock flags #
| Option | Effect |
|---|---|
sync / async | Synchronous / asynchronous writes. |
dirsync | Synchronous directory updates. |
lazytime / nolazytime | Lazy on-disk timestamp updates on or off. |
iversion / noiversion | Inode version counting on or off. |
silent / loud | Suppress or emit certain kernel messages. |
mand / nomand | Obsolete (removed from the kernel). Accepted and ignored with a note under -v. |
Filesystem-specific parameters #
Any token not recognised above is forwarded verbatim to the filesystem: key=value as a string parameter, a bare key as a flag. There is no built-in allow-list and no length limit; a rejection by the filesystem is reported with the offending key and the kernel's message.
For example, StrataFS takes its precedence-ordered directory stack through
strata=:
See StrataFS for the stack flags, merge
and write-routing model, and the stratafs inspection command.
Userspace-only tokens #
These never reach the filesystem. They include the meta-verbs remount, bind, rbind, move; the loop controls loop / loop=/dev/loopN, offset=, sizelimit= (numeric values accept K/M/G/T and KiB/MiB/… suffixes); the propagation tokens listed above; and defaults, which expands to rw,suid,dev,exec,async (later tokens override it).
Functional X-mount.* options #
| Option | Effect |
|---|---|
X-mount.mkdir[=mode] | Create the target directory if missing (default mode 0755; the alias of -m). |
X-mount.subdir=DIR | Attach subdirectory DIR of a freshly mounted filesystem at the target. Effective only for a new-instance mount; silently ignored (noted under -v) for bind/move/remount/propagation. |
X-mount.noloop | Suppress the implicit loop device for a regular-file source. |
X-mount.auto-fstypes=LIST | Constrain the -t auto probe to these types. |
X-mount.nocanonicalize[=source|target] | The -o form of -c; with =source or =target it disables canonicalisation for just that path. |
X-mount.idmap and X-mount.owner / group / mode are not supported and are rejected with a usage error: Peios ownership is SID/security-descriptor based, so the fix is to add a SID/ACE to the descriptor rather than remap or chown.
KACS mount policy #
This is the genuinely Peios-specific part of mount. A mount policy is a per-superblock setting that governs how FACS treats the filesystem; the full model is described in Mount policies and its detail pages. mount can set that policy at attach time:
| Option | Policy class |
|---|---|
-o policy=deny-missing | facs_deny_missing — a file with no SD is unreachable. |
-o policy=synth-ephemeral | facs_synthesize_ephemeral — a missing SD is synthesised in memory only. |
-o policy=synth-persist | facs_synthesize_persistent — a missing SD is synthesised and written back. |
--synth-sddl SDDL | Provide the mount-level SD template used during synthesis (only valid with a synth-* policy). |
policy=unmanaged is not user-settable; only the kernel sets the unmanaged class, for its own pseudo-filesystems. policy= is valid only on a new mount of a real filesystem — combining it with bind/move/remount/propagation, or with list mode, is a usage error. See Policy classes for what each class does and SD storage by filesystem for how the SD is physically stored.
The policy is applied to the detached filesystem before it is attached: if setting it fails, nothing is ever published with an unintended policy (no rollback needed). Because setting a mount policy is a SeTcbPrivilege-gated operation (see Managing mounts), a caller without that privilege gets a clean EPERM (exit 1) and no mount. --synth-sddl is validated client-side first — it must be well-formed SDDL and must include an owner.
Peios applies no coarse uid==0 check anywhere: the mount applet is not installed set-user-ID, and every privileged action is authorised per-operation by KACS.
Remounting #
-o remount changes the options of an existing mount without detaching it. Peios does not perform the util-linux "read the old flags and re-supply them" dance — the fd-based API applies deltas rather than resetting, so each remount touches only what you name. Per-mount attributes (ro/rw, nosuid, atime, …) are changed via mount_setattr; superblock flags, filesystem parameters and ro=fs/rw=fs go through the superblock reconfigure path. =recursive remounts the whole subtree, all-or-nothing.
A bind mount shares its source's superblock, so any superblock-level option on a bind remount (a Category-B flag, a filesystem parameter, or ro=fs/rw=fs) is refused as a usage error rather than silently mutating the shared superblock for every mount of that filesystem. Only per-mount VFS attributes are valid on a bind remount.
Loop devices #
A regular-file source is backed by a loop device automatically when the type is unspecified or the filesystem is recognised by libblkid; X-mount.noloop suppresses this. -o loop forces auto-allocation of a free device, loop=/dev/loopN names one, and offset= / sizelimit= select a region of the file (they imply loop and are an error against a real block device). A backing file already attached at the same offset and size is reused rather than doubly attached, to avoid corruption. Loops created by mount are auto-cleared by the kernel on unmount, so they do not leak; umount -d force-clears the rest (see umount).
Other flags #
| Flag | Effect |
|---|---|
-t, --types TYPE | Filesystem type, or auto. |
-o, --options LIST | Mount options (above); repeatable. |
-r, --ro, --read-only | Mount read-only (-o ro). |
-w, --rw, --read-write | Mount read-write and forbid the automatic read-only fallback on a write-protected device (-o rw). |
--source SRC / --target DIR | Name the operands explicitly. |
--target-prefix DIR | Prepend DIR/ to the target. |
-B, --bind / -R, --rbind / -M, --move / --beneath | The structural verbs. |
--make-*, --make-r* | Propagation changes. |
--exclusive | Force a unique superblock instance (no reuse). Meaningful only for multi-instance filesystems (e.g. tmpfs) or read-only block mounts; on an already-mounted writable block device it fails with EBUSY (exit 32). |
-m, --mkdir[=MODE] | Create the target directory if missing (default mode 0755). |
-L, --label LABEL / -U, --uuid UUID | Select the source by filesystem label / UUID. |
-c, --no-canonicalize | Do not canonicalise paths. |
-f, --fake | Dry run: parse, resolve and plan everything but skip the mount syscalls and the policy step. |
-v, --verbose | Narrate resolved values and each syscall; drain the kernel fs_context log. Repeatable but -vv is the same as -v. |
-l, --show-labels | In list mode, append each filesystem's label. |
--onlyonce | Skip the mount if it is already present (a driver-aware check against live mount state, not a naive string match). |
-N, --namespace NS | Operate inside mount namespace NS (a PID, an ns file path, or a named namespace). Source resolution happens in the caller's namespace; the mount lands in the target namespace. |
-i, --internal-only | Do not invoke a mount.<type> helper. |
-n, --no-mtab | Accepted and ignored (Peios has no mtab). |
--synth-sddl SDDL | KACS synth-policy template SD (above). |
-h, --help / -V, --version | Standard. |
No external mount helpers ship in this version, so a network or FUSE type fails with "no helper for type" (exit 1) — a clean deferral, not a crash.
List mode #
With no operands, mount prints one line per mount, read from /proc/self/mountinfo:
SOURCE on TARGET type FSTYPE (OPTIONS)
mount -t TYPE with no operands filters the list by filesystem type instead of mounting; here type lists (-t ext4,xfs) and no<type> negation (-t nosysfs) are honoured. -l appends [LABEL] to each line when a label is known.
Details of the rendering: the option field is the positional VFS-then-superblock merge with a coalesced ro/rw, not sorted; mountinfo octal escapes are decoded; control characters in the mount point become ?; and the source is shown as the kernel recorded it, with /dev/loopN resolved to its backing file and /dev/dm-N to /dev/mapper/<name>. The listing shows only mounts the caller's token may observe; a partial or empty view is correct, not an error, and the paths of filtered-out parents are never reconstructed.
For a device-oriented view of what is available to mount, use lsblk.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Incorrect invocation or a permission denial — bad flags, mutually-exclusive verbs, a lone operand, an invalid policy= or --synth-sddl, an authorisation failure (EPERM/EACCES), an embedded NUL, or "no helper for type". |
2 | System error (out of memory, no free loop device, cannot fork). |
4 | Internal error (an invariant failure). |
8 | Interrupted (SIGINT), after signal-safe cleanup. |
32 | Mount failure — a syscall in the flow failed, a label/UUID had no match, a probe was undetermined, or a --beneath/move/--exclusive kernel refusal. |
126 | An external mount.<type> helper was found but failed to execute (moot until a helper ships). |
Code 16 (mtab) is never produced. Code 64 (some succeeded, some failed) only arises when several source arguments are given and at least one succeeds while another fails.
umount
Peios / Using Peios / Mount policies
umount detaches a filesystem from the Peios mount tree. It is the counterpart of mount and, like it, reads live mount state from the kernel (/proc/self/mountinfo) rather than any /etc/mtab — Peios has none. Each operand is resolved against that live state and unmounted with umount2(2).
umount [-lfRA] [-dr] TARGET|SOURCE...
Operands and how they are resolved #
Each operand is either a mount point or a source:
- If the (canonicalised) operand is itself a mount point in the current mount table, it is unmounted directly. This is always unambiguous.
- Otherwise the operand is treated as a source and matched against the sources in the mount table. If it is mounted in exactly one place, that place is unmounted. If it is mounted in several places,
umountrefuses with an error naming the candidates — resolve it by naming a specific mount point, or use-Ato unmount all of them.
If an operand matches nothing, it is "not mounted": normally an error (exit 32), but -g makes that a success and -q suppresses the message.
Several operands may be given in one invocation, and options may be interspersed with them (umount /a -f /b). Paths are handled as opaque bytes; an embedded NUL is a usage error. By default each operand is canonicalised; -c disables that and additionally selects UMOUNT_NOFOLLOW so the kernel does not follow a symlink in the final component.
Recursive and all-targets unmounts #
Two flags expand a single operand into multiple unmounts. Within one operand the expansion stops on the first failure.
-R/--recursiveunmounts the target and everything mounted underneath it, including any over-mount stack at a single point, ordered deepest-first.-A/--all-targetsunmounts every mount point of the given source in the current mount namespace. This is the live-mountinfo "unmount everywhere" complement to source resolution; it is not an fstab feature.-A -Rtogether compose: for each mount point of the source, recurse underneath it.
-R and -r are mutually exclusive (combining them is a usage error, exit 1).
Flags #
| Flag | Effect |
|---|---|
-l, --lazy | Detach the filesystem now and clean up references later (MNT_DETACH). |
-f, --force | Force the unmount (MNT_FORCE), e.g. for an unreachable server. |
-R, --recursive | Unmount the target and everything under it (see above). |
-A, --all-targets | Unmount every mount point of the given source (see above). |
-d, --detach-loop | After unmounting, free the backing loop device. Best-effort and verified: it clears the device only if it still backs the mount just removed, so a recycled loop number is never clobbered. Usually redundant, since mount-created loops auto-clear on unmount. |
-r, --read-only | If the unmount fails, remount the filesystem read-only instead (via mount_setattr). Mutually exclusive with -R. |
-g, --graceful | Exit 0 when the target is absent or not mounted (rather than failing). Applied unconditionally. |
-q, --quiet | Suppress "not mounted" messages. |
-c, --no-canonicalize | Do not canonicalise paths; selects UMOUNT_NOFOLLOW. Applied unconditionally. |
-v, --verbose | Say what is being unmounted. Repeatable. |
-N, --namespace NS | Enter mount namespace NS (a PID, an ns file path, or a named namespace) before reading its mount table and unmounting. All work happens inside that namespace. |
-n, --no-mtab | Accepted and ignored (no mtab on Peios). |
-i, --internal-only | Do not invoke a umount.<type> helper (none ship). |
--fake | Dry run: resolve operands and report, but skip the unmount syscalls. |
-h, --help / -V, --version | Standard. |
The umount2 flag mapping is direct: -l → MNT_DETACH, -f → MNT_FORCE, -c (or a symlinked final component) → UMOUNT_NOFOLLOW. MNT_EXPIRE is deliberately not exposed, matching util-linux.
On privilege #
As with mount, there is no coarse uid==0 check: the applet is not set-user-ID and every unmount is authorised per-operation by KACS. Where util-linux silently suppresses an option for non-root users (-c, and -g's effectiveness), Peios applies it unconditionally.
Exit status #
| Code | Meaning |
|---|---|
0 | Success (or a -g no-op on an absent target). |
1 | Incorrect invocation or a permission denial — including -R with -r, a missing operand, an ambiguous source, an embedded NUL, or an authorisation failure. |
2 | System error (e.g. cannot read the mount table). |
8 | Interrupted (SIGINT). |
32 | Unmount failed, or the target is not mounted (unless -g). |
64 | Several source/target arguments were given and at least one succeeded while another failed. |
126 | An external umount.<type> helper was found but failed to execute (moot until a helper ships). |
A single -R / -A / -A -R invocation stops on the first failure and yields 32; the aggregate 64 only appears across multiple top-level arguments. To see what is currently mounted before unmounting, use mount with no arguments or lsblk.
lsblk
Peios / Using Peios / Mount policies
lsblk lists the block devices on the system and their relationships — disks, their partitions, and the device-mapper and loop devices layered on top. It is the device-oriented companion to mount: where mount with no arguments shows what is mounted, lsblk shows what is available to mount and what filesystem sits on each device.
lsblk [options] [DEVICE...]
By default it prints a tree, one row per device with children indented beneath their parent. Given one or more DEVICE operands (a name like sda, a full path like /dev/sda, or anything resolving to a device node), it re-roots the output at those devices.
Where the data comes from #
Each column is sourced from one of four places, and any that cannot be read degrades to an empty cell rather than aborting the run:
- sysfs (
/sys/block) — the device tree, sizes, and the topology/hardware columns. Peios leaves/sys/blockunpatched, so this is the standard no-udev path. - libblkid — filesystem identity:
FSTYPE,FSVER,UUID,LABEL, and the partition-table columns. libblkid is opened at runtime. - The device node's security descriptor —
OWNERandMODE, read the same wayls -lreads them. - The
/dev/disk/by-*symlink farm —ID-LINK. Populated by the device manager once it has run; there is deliberately no/run/udev/dataparser, so the column reads the links themselves and is empty in the initramfs, where no device manager runs.
Output modes #
The mode selects how the rows are formatted; it does not change which columns are shown.
| Flag | Mode |
|---|---|
| (default) | Indented tree. |
-l, --list | Flat list — the same columns, no tree glyphs. |
-J, --json | JSON. Flag columns render as bare booleans and MOUNTPOINTS as an array; children nest under a children key. |
-P, --pairs | KEY="value" pairs, one device per line. |
-r, --raw | Raw, space-separated. Values that could contain a space or control character are hex-escaped (\xNN) so fields stay parseable. |
-T, --tree[=COLUMN] | Force tree output even alongside -l, optionally attaching the tree glyphs to COLUMN instead of NAME. |
Columns #
With no column flag, lsblk prints the default set: NAME, MAJ:MIN, RM, SIZE, RO, TYPE, MOUNTPOINTS. Three flags swap in a preset, and -o names an explicit list (which wins over all of them):
| Flag | Column set |
|---|---|
-o, --output LIST | Exactly the comma-separated columns named (case-insensitive; an unknown name is a usage error). |
-O, --output-all | Every available column. |
-f, --fs | Filesystem view: NAME, FSTYPE, FSVER, LABEL, UUID, MOUNTPOINTS. |
-m, --perms | Permissions view: NAME, SIZE, OWNER, MODE. |
The available columns are NAME, KNAME, PATH, MAJ:MIN, FSTYPE, FSVER, LABEL, UUID, PTUUID, PTTYPE, PARTTYPE, PARTLABEL, PARTUUID, MOUNTPOINT, MOUNTPOINTS, SIZE, RO, RM, HOTPLUG, TYPE, OWNER, MODE, MODEL, VENDOR, REV, SERIAL, TRAN, HCTL, ALIGNMENT, MIN-IO, OPT-IO, PHY-SEC, LOG-SEC, STATE, ROTA, SCHED, PKNAME, and ID-LINK.
The OWNER and MODE columns #
OWNER and MODE describe the device node, not the filesystem on it, and they mirror ls -l exactly — there is no GROUP column and no POSIX permission bits, because access to a device node is governed by its security descriptor, not by a mode. OWNER is the owner SID (S-1-…); MODE is a three-character [type][x][+]: the device-type character (b for a block device, c for a character device), an x slot (never set for a block device), and a + when the DACL is inheritance-protected. When the SD cannot be read — for instance on a non-Peios host with no KACS syscalls — OWNER shows ? and MODE degrades honestly.
Filtering, sorting and shaping #
| Flag | Effect |
|---|---|
-a, --all | Include empty (zero-size) devices, which are hidden by default. |
-d, --nodeps | Do not print a device's holders or slaves (drop the children). |
-I, --include LIST | Show only devices with these major numbers (comma-separated). |
-e, --exclude LIST | Exclude devices by major number. The default is 1 (RAM disks); an explicit -e replaces that default, and -a clears exclusions entirely. |
-s, --inverse | Print dependencies in inverse order (holders above the devices they depend on). |
-M, --merge | Collapse a subtree shared by several parents (a multipath device) to a single occurrence. |
-E, --dedup COLUMN | Drop rows whose COLUMN value duplicates an earlier one. |
-x, --sort COLUMN | Sort siblings by COLUMN (numeric columns sort by value, not text). |
Formatting #
| Flag | Effect |
|---|---|
-b, --bytes | Print SIZE as an exact byte count instead of a human-readable value. |
-p, --paths | Print full /dev paths in the NAME column. |
-n, --noheadings | Omit the header row. |
-i, --ascii | Draw the tree with ASCII characters instead of box-drawing glyphs. |
-y, --shell | Render column keys shell-safe (MAJ:MIN becomes MAJ_MIN). |
-w, --width NUM | Truncate each table row to NUM columns wide. |
--sysroot DIR | Read sysfs, the mount table and /dev from DIR instead of / (chiefly for testing against a fixture). |
-h, --help / -V, --version | Standard. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Incorrect invocation (an unknown column, a malformed major-number or width value) or a failed run (for example, /sys/block is unreadable). |
lsblk uses this single non-zero code, matching util-linux. A per-device read failure is not fatal: it degrades the affected columns to empty or ? and the run continues. A broken output pipe (piping into head, for instance) is treated as success.
Disks and filesystems
Peios / Using Peios / Disks and filesystems
Creating a filesystem is the one operation that happens before Peios has any say over it. A block device holds bytes; a filesystem is a structure imposed on those bytes; only when that structure is mounted does the kernel begin making access decisions about what it contains. Everything on this page happens on the early side of that line.
Peios ships two upstream families: e2fsprogs for ext2/3/4, and dosfstools for FAT. Both are the tools you know from any Linux system. e2fsprogs carries a small Peios patchset, and the one place it diverges is security descriptors, which Formatting with security descriptors covers. dosfstools carries no functional patch at all, for a reason worth stating up front: a FAT filesystem has no extended attributes, so it has nowhere to keep a security descriptor and there is nothing for that work to extend.
Why these tools are packaged, not rewritten #
Most user-facing commands on Peios come from peiosutils, which reworks each tool for the Peios security model — mount grew a policy= option, lsblk reports SD-derived owner and mode, ls reads SDs rather than POSIX modes. mkfs and fsck are deliberately not in that set.
The reason is where the security seam falls. mount is the seam: it is the operation that takes a filesystem and places it under KACS, choosing the mount policy that governs every access from that point on. mkfs and fsck sit below the seam. They write and repair a Linux-compatible on-disk format that Peios deliberately mirrors byte for byte — an ext4 filesystem made on Peios is an ext4 filesystem, readable anywhere. Rewriting them would mean reimplementing a format Peios does not want to change, and taking on the correctness burden of a filesystem checker for no security benefit.
So Peios packages e2fsprogs from upstream and carries a patch series for the one thing upstream has no concept of.
What ships #
| Package | Contents |
|---|---|
e2fsprogs | The tools below. |
e2fsprogs-devel | Headers, linker symlinks and pkg-config files for building against the libraries. |
e2fsprogs-static | Static archives. |
libext2fs, libe2p, libcom-err, libss, libuuid | The runtime shared libraries, packaged separately so a consumer can depend on one without pulling the tools. |
The tools themselves:
| Tool | Purpose |
|---|---|
mke2fs, mkfs.ext2, mkfs.ext3, mkfs.ext4 | Create a filesystem. This is where Peios security descriptors enter. |
e2fsck, fsck.ext2, fsck.ext3, fsck.ext4, fsck | Check and repair a filesystem. |
tune2fs | Change parameters on an existing filesystem, including enabling features after the fact. |
resize2fs | Grow or shrink a filesystem. |
dumpe2fs | Print superblock and block-group information. |
debugfs | Interactive low-level access to a filesystem image, including reading and writing extended attributes directly. |
blkid, findfs | Identify filesystems by label, UUID or type. |
e2label, e2image, e2undo, e2freefrag, filefrag, badblocks, logsave | Labelling, imaging, undo, fragmentation and block-scanning utilities. |
chattr, lsattr | Read and set ext2/3/4 inode attributes. |
uuidgen | Generate a UUID. |
libuuid comes from e2fsprogs; libblkid comes from the util-linux libraries. The split is arbitrary but fixed — each library has exactly one owning package, so the two sources never both ship the same file.
From dosfstools:
| Tool | Purpose |
|---|---|
mkfs.fat, mkfs.vfat, mkfs.msdos | Create a FAT12/16/32 filesystem. |
fsck.fat, fsck.vfat, fsck.msdos | Check and repair a FAT filesystem. |
fatlabel | Read or set a FAT volume label. |
The pre-4.0 aliases (mkdosfs, dosfsck, dosfslabel) are deliberately not shipped — they exist for compatibility with a command-line history Peios does not have.
Where these tools live #
Everything above is in /usr/bin, reached as /bin through the runtime view. None of it is in /sbin: that is for daemons, which appear in service definitions, and a filesystem tool is a privileged binary a person runs.
The fsck.<type> backends are the exception, and they sit in /usr/libexec/fsck/:
/libexec/fsck/fsck.ext2 fsck.ext3 fsck.ext4
fsck.fat fsck.vfat fsck.msdos
These are not commands you type. fsck picks a checker by exec'ing fsck.<type> for the filesystem type it detects or is given, so they are a private interface between the front-end and its backends — which is exactly what libexec distinguishes. fsck searches /libexec/fsck first, then the directories on your PATH, so a third-party checker installed elsewhere on PATH is still found.
The mkfs.<type> names stay in /usr/bin for the opposite reason: Peios ships no mkfs front-end, so nothing ever dispatches on them and they are only ever typed.
FAT and the EFI system partition #
The reason Peios ships FAT tooling at all is the EFI system partition. UEFI requires the ESP to be FAT, so a system that cannot format FAT cannot create its own boot partition.
An ESP is also the clearest case of a filesystem that holds no access control of its own. There is no extended-attribute channel, so no security descriptor is ever written to it, and none can be. Its entire access policy comes from the mount — necessarily one of the synthesising classes, since facs_deny_missing on a filesystem where every file is permanently missing an SD would make the whole partition unreachable. See SD storage by filesystem.
The practical consequence: the protection on your boot partition is the mount policy and the physical security of the disk, not a descriptor on the files. Treat the contents accordingly.
Where this sits in a running system #
debugfs is the tool to reach for when you want to inspect an image offline — without mounting it, and therefore without KACS being involved at all. It reads and writes extended attributes directly, which makes it the way to confirm that a security descriptor really landed on disk:
debugfs -R "ea_list <2>" /dev/vda2
Inode 2 is always the root directory of an ext filesystem, so this asks what extended attributes the filesystem's root carries.
e2fsck has a specific place in boot. Checking and repairing the root filesystem is the initramfs's job, not peinit's — by the time peinit runs, the root is already mounted, and a filesystem checker cannot repair a filesystem that is in use. See The initramfs stage.
What is not here yet #
Partition tables other than GPT. part writes GPT and only GPT. Peios boots through UEFI with no bootloader, so MBR has nothing to do on a Peios system — but a disk that already carries one is recognised and named rather than silently overwritten. Resizing, moving and MBR↔GPT conversion do not exist either. See Partitioning.
Filesystems other than ext2/3/4 and FAT. The mount side understands XFS, Btrfs and NTFS as well — see SD storage by filesystem — but Peios ships creation tools only for the ext and FAT families.
Where to start #
For the security-descriptor model — why a filesystem you intend to keep should carry a real SD from the moment it is created, and how the tree gets one — read Formatting with security descriptors.
For the exact command surface, including the extended options and the Peios defaults baked into the binary, read mke2fs.
For how these tools are put to work copying a live system onto a disk that then boots itself, read Installing to disk.
For the names a disk keeps across reboots — /dev/disk/by-uuid and its siblings, and the device manager that maintains them — read Stable device names.
For what happens to a filesystem once it is attached to the mount tree, read Mount policies.
Stable device names
Peios / Using Peios / Disks and filesystems
The kernel names a disk by the order it found it in: /dev/vda, /dev/sda, /dev/nvme0n1. That order is whatever the hardware produced this time — a second controller that probed faster, a USB stick that was plugged in, a disk that moved to another port — so the same disk is not guaranteed the same name on the next boot. A name of that kind is fine for a command you type now and wrong for anything written down: a boot cmdline, a mount reference, a script.
Peios keeps kernel names for the moment and gives every disk a set of stable names for everything else. They live under /dev/disk/, as symlinks to whatever kernel name the device happens to have:
| Directory | Keyed by | Example |
|---|---|---|
by-uuid | the filesystem's UUID, written when it was formatted | 4ED7-A6AC -> ../../vda2 |
by-label | the filesystem's label | PEIOS -> ../../vda |
by-partuuid | the GPT partition entry's UUID | 2036ae68-…-f118299d5446 -> ../../vda2 |
by-partlabel | the GPT partition entry's name | |
by-id | the hardware's own identity: model and serial, or WWN | nvme-QEMU_NVMe_Ctrl_peiosnvme1 -> ../../nvme0n1 |
by-path | the bus position the device sits at | |
by-diskseq | the kernel's monotonic disk sequence number, for this boot only |
Which one to use depends on what you mean. A filesystem is by-uuid — it follows the data if the disk is cloned, and it is what root=UUID= on the kernel command line names. A partition independent of what is on it is by-partuuid. A physical disk whatever is written to it is by-id. by-label is the human-friendly one and the only one that is not unique by construction.
Partitions appear under the same keys with the parent's identity plus -partN, so by-id/nvme-…-part2 is the second partition of that NVMe disk.
Who creates them #
The names are made by the device manager, eudev, which peinit starts first in phase 2 of boot. As each block device appears — at boot, when it replays the kernel's enumeration, and afterwards whenever a device is plugged in — the device manager probes it (its partition table, and the filesystem signature on each partition) and creates the matching links. Unplug the device and the links go away.
That is the same daemon that loads a driver for each device the kernel reports, so a disk behind a modular controller gets its driver, its kernel name and its stable names from one pass. See Boot and boot modes for where the service sits in the boot order; a service that opens a device by stable name should Requires it, because peinit does not release eudev's dependents until the boot-time replay has finished.
The same idea applies to network interfaces. The kernel names them eth0, eth1 in probe order; the device manager renames each one by where it sits — enp0s3 for the card in PCI slot 3 on bus 0, eno1 for an onboard port the firmware numbers — so a configuration written against the name survives a second card or a driver that probes faster next time. net.ifnames=0 on the kernel command line turns the renaming off.
The device manager does not decide who may open a device. That is the security descriptor on the node, seeded on /dev before any of this runs — see SD storage by filesystem.
Where they are not available #
The initramfs has none of them. No device manager runs there — only a single pass that loads drivers, described in The initramfs stage. The initramfs still honours root=UUID=, but it resolves the UUID by probing each block device directly rather than by looking in /dev/disk/by-uuid, and lsblk does the same. That is why the installer's cmdline works on a machine the initramfs has never seen: it never depended on the links.
by-diskseq does not survive a reboot. The sequence number is assigned in the order devices appear during this boot, which is precisely the property the other directories exist to avoid depending on. It is there for tools that need to tell a re-plugged device from the one it replaced.
Where to go next #
For writing a partition table that gives every partition a by-partuuid name, read Partitioning.
For how the installer records the root filesystem's UUID into the kernel command line, read Installing to disk.
Partitioning
Peios / Using Peios / Disks and filesystems
A disk arrives from the factory as one undifferentiated run of sectors. Before a filesystem can live on it, something has to write down where each one begins and ends. That is a partition table, and on Peios the tool that writes one is part.
part manages GPT and nothing else. That is not an omission waiting to be filled: Peios boots through UEFI with no bootloader and no boot manager, so the MBR layout it would otherwise support has nothing to do on a Peios system.
The shape of the tool #
If you have used Windows, part occupies the slot diskpart occupies — but it is not the same shape, and the difference is deliberate.
diskpart | part | |
|---|---|---|
| how you use it | an interactive shell: select disk 0, then act on it | one command, one device, one job |
| what it covers | partitions, formatting, drive letters, dynamic disks | the partition table |
Everything else diskpart bundles already has a home here: mke2fs and mkfs.vfat make filesystems, mount mounts them, and Peios has no drive letters. And a select-then-act model is a hazard in a script, which is what usually calls part — a command that acts on "whatever was selected earlier" is one stray line away from acting on the wrong disk.
Listing disks #
Run part list with no arguments to see every disk on the machine:
# part list
DEVICE SIZE CONTENTS
/dev/vda 8.0G gpt, 2 partitions
vda1 512M esp EFI system partition
vda2 7.5G linux Peios root
/dev/vdb 8.0G no partition table
This is the view to start from, because it answers the question that precedes every other one: which disk did you mean? The partition names are the ones the kernel actually created — vda1 on a virtio or SATA disk, nvme0n1p1 on an NVMe one — so they are what you can pass straight to mkfs or to the installer.
The structure here comes from the kernel, not from reading a partition table, so a disk carrying a format part cannot manage still shows its partitions. A disk it cannot read at all is still listed, with ? for its contents — the disk you cannot read is exactly the one worth knowing about.
Name a disk to see it in full:
# part list /dev/vda
/dev/vda: 16777216 sectors of 512 bytes (8.0G)
Label: gpt
Disk GUID: DE942295-427C-411D-8023-B689D8BEE907
Usable: 34 .. 16777182
# START END SIZE TYPE NAME
1 2048 1050623 512M esp EFI system partition
2 1050624 16777182 7.5G linux Peios root
Free (aligned): 0B in 0 extent(s), largest 0B
Free (aligned) counts space a new partition could actually occupy, which is not the same as unallocated sectors — the run below the first alignment boundary can never hold one. Reporting raw free space would promise room that add would then refuse to use.
part verify checks the same table's structure: both checksums, the two headers agreeing with each other, no overlaps, everything aligned and inside the usable range.
Creating a table #
# part create /dev/vda --yes
/dev/vda: wrote a new GPT (DE942295-427C-411D-8023-B689D8BEE907)
# part add /dev/vda --size 512M --type esp --name "EFI system partition" --yes
/dev/vda: partition 1 at 2048..1050623 (512M)
# part add /dev/vda --size max --type linux --name "Peios root" --yes
/dev/vda: partition 2 at 1050624..16777182 (7.5G)
add puts each partition in the first free run that fits, aligned to 1 MiB. --size max takes the largest free run rather than merely the last one, so it still does the obvious thing on a disk with a gap in the middle.
part del /dev/vda 2 --yes removes a partition by number. It frees the space and the slot; it does not touch the data that was in it.
Sizes are sectors unless you say otherwise #
--size takes K, M, G, T — powers of 1024 — or max. A bare number is a sector count, not bytes. --size 2048 is 1 MiB on a 512-byte-sector disk, and you can write 2048s to say so explicitly. Reading it as bytes would silently produce a partition a thousand times smaller than intended.
Types #
--type takes a short alias or a raw GUID:
| Alias | Meaning |
|---|---|
esp | EFI system partition |
linux | Linux filesystem data |
swap | Linux swap |
msdata | Microsoft basic data |
The list is short on purpose. Every type GUID in circulation would be a catalogue to keep current, and since a raw GUID is always accepted, nothing is unreachable for want of an alias.
Names #
Up to 36 UTF-16 code units — fewer if you use characters outside the Basic Multilingual Plane, which cost two units each. A longer name is refused rather than shortened. A partition name is how you identify the thing you are about to format, and a tool that quietly truncates it makes the label on your screen disagree with the label on the disk.
What it will not do #
part is the one tool on the system whose mistakes cannot be undone, so it is deliberately hard to point at the wrong thing.
It requires --yes. There is no interactive "are you sure": the usual caller is a script, and a prompt nobody can answer is worse than no prompt at all.
It refuses a partition. Naming /dev/vda1 where you meant /dev/vda would write a GPT inside a partition — a table that looks valid to anything reading that partition directly, and is invisible to everything else.
# part create /dev/vda1 --yes
part: /dev/vda1 is a partition, not a whole disk; did you mean /dev/vda?
It refuses a disk with anything mounted on it. Not just the disk itself — any partition of it.
It refuses a table it did not create. This is the important one:
# part create /dev/vdb --yes
part: this disk carries an MBR (dos) partition table, which part cannot manage;
pass --force to replace it — every partition on it will be lost
part list says what it found, not merely that it found no GPT — because "no GPT" is ambiguous between a blank disk and a disk holding somebody's data, and those deserve opposite treatment:
| What is there | What part says |
|---|---|
| an MBR | "an MBR (dos) partition table, which part cannot manage" |
| an Apple, BSD, Sun or SGI label | names it |
| a GPT whose header is corrupt | "the table may be damaged" |
| a filesystem written straight to the disk | "a <type> filesystem … with no partition table" |
| genuinely nothing | "no partition table" |
--force is the way through, and it is a second confirmation, separate from --yes. --yes means "I mean this destructive operation"; --force means "and I know it destroys a table that was already there". Requiring both is proportionate for an operation with no undo.
Alignment, and why 1 MiB #
Every partition starts on a 1 MiB boundary — 2048 sectors on a 512-byte-sector disk, 256 on a 4096-byte one. The number is not arbitrary: 1 MiB divides every erase block and RAID stripe width in practical use, so an aligned partition never straddles one. A misaligned filesystem pays a read-modify-write cycle on every boundary-crossing write, for the life of the filesystem.
part reads the logical sector size from the kernel rather than assuming it, so a 4Kn disk gets a correct table rather than one whose every structure is in the wrong place.
Exit status #
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | usage error, or the operation failed |
| 2 | could not read or write the device |
| 3 | refused by a safety check |
3 is separated from 1 on purpose. A refusal is part working correctly, not malfunctioning, and a script should treat "this disk is not what you said it was" differently from "partitioning broke". peios-install relies on exactly this distinction.
Where to go next #
To put a filesystem on what you just created, read Formatting with security descriptors and mke2fs.
To have the installer do all of this for you, read Installing to disk — peios-install --whole-disk runs exactly the three commands above before it formats anything.
Formatting with security descriptors
Peios / Using Peios / Disks and filesystems
A freshly created filesystem contains one directory — its root — and that directory has no security descriptor. Nothing in the ext4 on-disk format has any concept of one. This is a problem the moment the filesystem is mounted under a FACS-managed policy, because facs_deny_missing means exactly what it says: a file with no SD is unreachable, and that includes the root directory you are trying to enter.
There are two ways out. One is to let the kernel invent an SD at mount time. The other is to put a real one on the filesystem when it is created. Peios can do both, and which is appropriate depends on whether the filesystem is something you are passing through or something you intend to keep.
Synthesised versus stamped #
Mount-time synthesis is the right answer for media you do not own. A USB stick formatted on another system, a read-only image, an NTFS volume from Windows — these have no Peios SDs and should not acquire any. facs_synthesize_ephemeral gives every inode an SD in memory, derived from the mount's template, and never writes it back.
It is the wrong answer for a filesystem that is going to be a Peios system. A synthesised SD is a property of how the filesystem was mounted, not of the filesystem. Mount it with a different template and the whole tree's access policy changes; mount it somewhere that does not set a template and you get whatever the default is. The access policy of a system you own should live on that system, not in the command that attached it.
Stamping puts it there. mke2fs accepts a security descriptor at format time and writes it to the filesystem's root directory, so the filesystem carries its own policy from the moment it exists and mounts cleanly under facs_deny_missing with no template required.
The root SD, and what it reaches #
You give the descriptor as SDDL, and mke2fs writes it to the security.peios.sd extended attribute on the root directory and on lost+found:
mke2fs -t ext4 -E root_sddl="O:SYG:SYD:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)" /dev/vda2
That descriptor makes SYSTEM the owner and grants both SYSTEM and BUILTIN\Administrators full control. The OICI flags on each ACE — object-inherit and container-inherit — are what make it reach further than the root directory. Every file and directory subsequently created anywhere on the filesystem derives its own SD from that one through ordinary inheritance.
That is worth stating plainly, because it cuts both ways. A single inheritable ACE on the root is, in practice, the access policy of the entire filesystem. It is a complete answer for a system tree where everything should be administrator-owned. It is not a way to express "readable system tree, private home directories" — one inherited ACL cannot say two different things, and the ACEs that would make /home/alice private have to come from somewhere else.
When you read an SD, treat the ID flag as a diagnostic: ID is INHERITED_ACE, so that descriptor was derived rather than stored. When you see it, the file's policy is not on the file — go and look at the ancestor it came from.
Populating a tree with per-node descriptors #
Inheritance from the root covers files created on the filesystem. It does not cover a tree copied onto it, where the nodes already have descriptors of their own that need preserving.
mke2fs -d populates a new filesystem from a directory on the build host, and on Peios it is security-descriptor aware. For each node it copies, it computes the SD the node should have in its new home, by combining two inputs:
- The explicit descriptor the source node carries — what the creator of that node wanted for it.
- The parent's descriptor in the new filesystem, which supplies the inheritable ACEs.
The two are merged by the same rules that govern inheritance on a live system: explicit ACEs first, inherited ACEs appended after them. A node whose source carries no explicit descriptor is left alone, and inherits normally on first access instead. A node whose parent has no descriptor keeps its explicit one verbatim.
The walk is top-down, and directories are stamped before their contents are visited, so every child reinherits against a parent whose descriptor has already been written.
Why the source uses a different xattr #
The explicit descriptor on the source tree is read from user.peios.sd, not security.peios.sd. This looks like an inconsistency and is not.
security.peios.sd is the canonical, on-disk home for a descriptor — and precisely because it is canonical, it is protected. On a live Peios filesystem the kernel seals it: direct extended-attribute operations on it are refused unconditionally, and access goes through kacs_get_sd and kacs_set_sd instead. On a Linux build host, writing anything in the security.* namespace needs CAP_SYS_ADMIN.
Neither is available to a tool staging a tree. user.peios.sd is subject to neither restriction, which makes it the portable carrier: any user can attach it, on any host, and it travels with the tree through ordinary archive and copy operations. mke2fs -d reads it, computes the result, and writes the answer to the canonical security.peios.sd on the new filesystem. It also skips both names when copying the node's other extended attributes across, so neither the carrier nor a stale canonical value is propagated verbatim.
This all works offline #
None of the above needs a running Peios kernel. The reinheritance computation is pure userspace, and mke2fs writes the extended attributes through the ext2 library, addressing the image directly rather than going through the host's filesystem layer. That bypasses the host's own security module and the Peios seal alike, for the simple reason that neither is in the path.
The practical consequence is that a Peios filesystem, with its full security policy in place, can be built on a machine that is not running Peios.
Where to go next #
For the exact syntax of the extended options and the defaults mke2fs applies on Peios, read mke2fs.
For how a descriptor is physically stored once written, including when an oversized one needs its own inode, read SD storage by filesystem.
For what the mount policy does with the descriptor you stamped — and what happens on a filesystem that has none — read Policy classes.
mke2fs
Peios / Using Peios / Disks and filesystems
mke2fs creates an ext2, ext3 or ext4 filesystem. Peios packages it from upstream e2fsprogs, so the generic surface — -t, -b, -L, -O, -i, -m, the full extended-option list — is exactly the upstream one and its canonical documentation is the mke2fs(8) man page shipped with the package.
This page documents only what Peios adds, which is security descriptors and a changed set of defaults.
mke2fs [-t ext4] [-E root_sddl=SDDL | root_sd_file=PATH] [-d DIRECTORY] DEVICE
Extended options #
Peios adds two options to -E. Both set the security descriptor written to the new filesystem's root directory and to lost+found, in the security.peios.sd extended attribute.
| Option | Meaning |
|---|---|
root_sddl=SDDL | The descriptor as an inline SDDL string. |
root_sd_file=PATH | The descriptor as SDDL read from PATH. |
root_sd_file exists because -E takes a comma-separated list and SDDL contains commas. Any descriptor with more than one ACE, or with a conditional expression, is easier to pass in a file than to quote on a command line.
The file holds SDDL text, not binary — it is the same string root_sddl would take, in a file. Trailing newlines are stripped, so an ordinary one-line text file works. Anything else in the file is part of the descriptor: there is no comment syntax and no blank-line handling.
Both options are parsed in the order they appear. If you give both, or the same one twice, the last one wins.
If the SDDL does not parse, mke2fs prints Invalid root SDDL: followed by the string it was given, and exits without creating a filesystem.
Neither option has a default. Omit both and the filesystem is created with no security descriptor on its root, which is upstream behaviour — mount it under a synthesising policy or it will be unreachable.
Example #
mke2fs -q -t ext4 -E root_sddl="O:SYG:SYD:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)" /dev/vda2
Confirm the result without mounting the filesystem:
debugfs -R "ea_list <2>" /dev/vda2
Extended attributes:
security.peios.sd (96)
Inode 2 is the root directory. To read the descriptor back as bytes rather than just confirm its presence, use debugfs -R "ea_get -V <2> security.peios.sd".
Security-descriptor-aware population #
-d DIRECTORY populates the new filesystem from an existing directory. On Peios this pass also computes a security descriptor for each node it creates.
The source node's explicit descriptor is read from its user.peios.sd extended attribute — not security.peios.sd, which is sealed on a live Peios filesystem and requires CAP_SYS_ADMIN on a Linux build host. Both names are excluded from the generic extended-attribute copy, so neither is propagated verbatim.
For each node:
| Source node | Parent in the new filesystem | Result |
|---|---|---|
Has user.peios.sd | Has a descriptor | Explicit and inherited ACEs merged, written to security.peios.sd. |
Has user.peios.sd | Has none | The explicit descriptor written verbatim. |
Has no user.peios.sd | Either | Nothing written. The node inherits on first access instead. |
Directories are stamped before their contents are visited, so a child always reinherits against a parent whose descriptor is already on disk.
The merge follows the same rules as inheritance on a live system, and runs entirely in userspace — no kernel and no KACS are involved, so a populated Peios filesystem can be built on a host that is not running Peios.
Peios defaults #
mke2fs reads its defaults from a profile. Peios changes three of them, and the changed profile is compiled into the binary, so it applies whether or not a configuration file exists.
| Setting | Upstream | Peios | Reason |
|---|---|---|---|
inode_size | 256 | 512 | Keeps a typical security descriptor in the inode's inline extended-attribute space, where reading it costs no extra I/O. |
default_mntopts | acl,user_xattr | user_xattr | Peios uses security descriptors, not POSIX ACLs. |
| ext4 features | — | + ea_inode | Lets an oversized descriptor spill into its own inode rather than failing to fit. |
base_features, blocksize, inode_ratio and enable_periodic_fsck are unchanged from upstream.
The ext4 quota feature is deliberately not enabled. The Peios kernel is built with CONFIG_QUOTA and CONFIG_QUOTACTL but without CONFIG_QFMT_V2, and ext4 refuses to mount a filesystem carrying the quota feature unless the vfsv1 on-disk format is compiled in. Enabling it in the profile therefore produced filesystems the kernel would not mount. Turning it on again is a pair of changes that have to land together — the kernel option, then the feature.
Resolution order for the profile, highest first:
- The file named by the
MKE2FS_CONFIGenvironment variable, if set. /etc/mke2fs.conf, if present.- The compiled-in Peios profile.
Peios ships no /etc/mke2fs.conf, so the compiled-in profile is what you get unless you deliberately supply a file. Supplying one replaces the profile wholesale — the compiled-in values are a fallback, not a layer underneath — so a partial configuration file silently reverts every setting it does not mention to the upstream default.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Failure. Includes an unparseable root_sddl/root_sd_file value, an unreadable root_sd_file, and a failure to write the descriptor to the new filesystem. |
mke2fs distinguishes no further; unlike e2fsck, it has no bitwise-summed status. When the security-descriptor options fail they report the offending value on standard error before exiting, and no filesystem is created.
See also #
For the model behind the two options — why stamping beats mount-time synthesis, and what one inheritable ACE on the root does and does not express — read Formatting with security descriptors.
For reading and rewriting descriptors on a mounted filesystem, read The sd command.
For how the descriptor is stored, and when ea_inode becomes load-bearing, read SD storage by filesystem.
Installing to disk
Peios / Using Peios / Disks and filesystems
A live Peios runs from a read-only squashfs with a tmpfs stacked on top, so every write it accepts is discarded at reboot. Installing to disk replaces that arrangement with a writable filesystem that survives — and, less obviously, replaces a security policy chosen at mount time with one the filesystem carries itself.
Installation is five steps and one retirement. Nothing about it is magic, and all of it can be done by hand.
Two ways to invoke it #
peios-install --yes --whole-disk /dev/vda # partition the disk, then install
peios-install --yes /dev/vda1 /dev/vda2 # install onto partitions that exist
The first form runs part before anything else — a fresh GPT, a 512 MiB ESP, and a root filling the remainder — and then proceeds exactly as the second. The whole disk is erased.
If the disk already carries a partition table part did not create, the install stops rather than overwriting it; --force is how you say you meant it. That refusal happens before the first mkfs, so nothing has changed when it does.
The second form is for any layout other than the one above: partition however you like with part, then name the two partitions.
What the installer does #
1. Format the EFI system partition. UEFI requires FAT, so this is mkfs.vfat -F 32. A FAT filesystem cannot hold a security descriptor and never will, so the ESP's access policy comes entirely from its mount — necessarily one of the synthesising classes.
2. Format the root, with a descriptor. This is the step with no equivalent on other systems:
mke2fs -t ext4 -E root_sddl="O:SYG:SYD:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)" /dev/vdb2
The descriptor is written to the root directory's security.peios.sd at format time, so the filesystem is administrable from the instant it exists. Because both ACEs are inheritable, everything created inside it derives its own descriptor from that one. See Formatting with security descriptors.
3. Copy the system. cp -ax, which preserves owner, DACL, SACL, timestamps, links and extended attributes, and stops at filesystem boundaries. Every one of those is required rather than best-effort, so a descriptor that cannot be carried across stops the install instead of quietly downgrading it. Mountpoints are recreated as empty directories rather than copied into: /proc, /sys and /dev are mount-moved into the new root by prelude at boot, and /bin, /etc, /lib and the rest are StrataFS views mounted over theirs. What lives behind those views — /usr and /lcl — is ordinary content on the root filesystem, and is copied in full.
4. Write the kernel command line. The installed system needs root=, naming the filesystem that did not exist until step 2. That cannot be package data, so it is generated: the disk-boot package ships a template at /usr/share/disk-boot/cmdline carrying everything stable, and the installer appends root=UUID=<the new root> and writes the result to /lcl/etc/boot/cmdline on the target. Package data supplies the general part; the operator tree holds the per-install part.
5. Build the boot artifact. mkuki bundles the kernel, the initramfs and that command line into a single EFI binary, written to the ESP at EFI/BOOT/BOOTX64.EFI.
That last path is the removable-media fallback, which UEFI firmware boots without an NVRAM entry. A Peios installation therefore needs no bootloader and no boot manager — there is nothing between the firmware and the kernel.
What it refuses before it starts #
Both partitions you name are about to be formatted, so the installer checks them before it does anything irreversible. It stops if either is not a block device, if you name the same partition twice, or — the one worth stating plainly — if either is currently mounted:
# peios-install --yes /dev/vda1 /dev/vda2
peios-install: /dev/vda2 is mounted; refusing to format it
That last check is what stands between you and naming the partition you are running from. All of it happens before the first mkfs, so a rejected install has changed nothing.
The first-account service is retired, the account is not #
Your accounts come across. What does not is the thing that creates them.
A live image ships lpsd-first-account: a oneshot service that runs lps add at boot to create the development account. It is written for exactly one situation, which is a live image — the root there is a tmpfs, so every boot starts from an empty store and the provisioner has work to do.
An installed system is the other situation. The accounts themselves live in lpsd's store at /var/state/lpsd/principals, which is ordinary content on the root filesystem and is copied like everything else. So the installed machine already has the account before it first boots, and re-running a provisioner against a populated store is not a harmless no-op: the script carries no idempotence guard, so lps add fails on the name that already exists and the service crashes on every boot. Once installation grows a "choose a password" step, it would be worse than noisy — a provisioner that reasserts the image's credential would undo the operator's choice at the next reboot.
So before it copies anything, the installer deletes the service from the registry:
reg del 'Machine\System\Services\lpsd-first-account'
Note which registry. It removes the key from the live system it is running on, not from the target — the registry is a live database served by registryd, and the only thing that can safely edit it is the registryd currently holding it open, so the removal happens at the source and the copy never carries it. Running the installer again, or running it from an already-installed system, finds nothing to remove and says so. The removal is checked afterwards and a failure stops the install, which happens before either partition is formatted and therefore costs nothing.
Use UUIDs, not device names #
Step 4 records the root by UUID, and the reason is worth understanding rather than copying.
The disk that is /dev/vdb while you install — second device, behind the install medium — is /dev/vda when you boot it with the medium removed. A device name baked into the command line is a name for where a disk was plugged in, not for the disk, and it stops being true the moment the arrangement changes.
The initramfs resolves the UUID by probing block devices directly rather than reading /dev/disk/by-uuid, because no device manager runs in the initramfs and those directories do not exist there — see Stable device names.
How the installed system boots #
The initramfs contains two hooks that can mount a root, and both are present in every image:
| Hook | Mounts |
|---|---|
mount-root.sh | a live medium's squashfs, with a tmpfs overlay above it |
mount-root-disk.sh | an installed root partition, directly |
Both run at every boot. root= on the command line decides which one acts; the other exits successfully having done nothing. Boot hooks covers the mechanism.
Two differences in what the disk hook does are worth calling out, because they are the point of installing at all.
It mounts the partition directly — no overlay. The live path stacks a tmpfs because the layer beneath it is read-only. An installed root is writable, so an overlay would serve only to throw away everything written to it.
It mounts policy=deny-missing, and runs no seed-sd. A live root has no descriptors at all — the squashfs ships none — so the live system mounts it synth-ephemeral and has KACS invent one per inode, then seeds a single inheritable descriptor onto the tmpfs above. An installed root needs none of that: it carries a real descriptor, written at format time, so a file without one is a fault worth surfacing rather than a gap to paper over.
This is the substantive difference between the two, and it is easy to check on a running installed system:
# sd show /
Owner: LocalSystem (S-1-5-18)
DACL: (2 ACEs)
[0] allow LocalSystem (S-1-5-18) 0x10000000 [CI,OI]
[1] allow BUILTIN\Administrators (S-1-5-32-544) 0x10000000 [CI,OI]
No ID flag on either ACE. ID is INHERITED_ACE, so its absence means this descriptor is explicit — stored on the root inode by mke2fs, not derived from an ancestor and not synthesised at mount. The filesystem's access policy is a property of the filesystem.
What the installer does not do #
It does not choose your partition layout. --whole-disk writes one specific arrangement — a 512 MiB ESP and a root filling everything else — and that is all it will ever write. Anything else is a job for part followed by the two-partition form of the installer.
It does not choose a layout. One ESP, one root, no separate /home, no swap, no encryption. Each of those is a real thing to want and none of them exists yet.
It does not express a per-directory access policy. The whole installed tree inherits from that single descriptor on the root. That is a complete answer for a system tree where everything is administrator-owned, and it is not a way to say "readable system tree, private home directories" — one inheritable ACL cannot say two different things. Per-subtree descriptors are the work that makes that expressible.
Where to go next #
For the format-time descriptor and how a populated tree gets its own, read Formatting with security descriptors.
For the hook mechanism the two root-mounting hooks share, read Boot hooks.
For what deny-missing does with a file that has no descriptor, and why the live path cannot use it, read Policy classes.
StrataFS
Peios / Using Peios / Disks and filesystems / StrataFS
StrataFS presents several ordinary directories as one merged directory tree. The directories remain ordinary and independently manageable at their real paths; the StrataFS mount only supplies the merged view.
Strata are ordered from highest to lowest precedence. For each name, the first stratum that holds it is its provider. Directories at the same name merge, so their children are resolved in the same precedence order. A non-directory provider masks every lower object at that name, including a lower directory's whole subtree.
A /bin example #
Suppose /usr/bin contains packaged programs and /lcl/bin is for local
changes. Mount this stack at /bin:
/lcl/bin has higher precedence and receives newly-created objects. The +ro
on /usr/bin means “do not modify this stratum through /bin”. It does not
make the real /usr/bin mount read-only: an authorised writer can still modify
/usr/bin/tool directly at /usr/bin/tool.
Reading /bin/tool uses /lcl/bin/tool when it exists, otherwise
/usr/bin/tool. Writing an existing packaged tool through /bin copies it to
/lcl/bin first and changes the copy. Removing that copy through /bin
restores the unchanged /usr/bin version to view, because StrataFS does not use
whiteouts.
The base Peios topology #
The fsbase package installs the mount-rootfs-stratafs-base.sh boot hook
in the initramfs. It runs after the deployment-specific hook has mounted the
real root and before prelude hands off to it, mounting the conventional
root-level views as one boot step:
| View | Strata, highest precedence first |
|---|---|
/bin | /lcl/bin+create, /usr/bin+ro+am |
/sbin | /lcl/sbin+create, /usr/sbin+ro+am |
/lib | /lcl/lib+create, /usr/lib+ro |
/libexec | /lcl/libexec+create, /usr/libexec+ro+am |
/share | /lcl/share+create, /usr/share+ro+am |
/include | /lcl/include+create, /usr/include+ro+am |
/etc | /system/retc, /lcl/etc+create, /usr/etc+ro+am |
/conf | /lcl/conf+create, /usr/conf+ro+am |
am permits an optional vendor directory to be absent when the system boots
and makes it participate automatically if a later package creates it. The
operator create directories are provisioned by fsbase; their absence is a
boot error rather than something StrataFS silently creates, because their own
security descriptors govern creation through each view.
/lib views /usr/lib rather than the architecture triplet directory beneath
it, so /lib/modules and /lib/firmware resolve. Both matter: kmod has
/lib/modules compiled in, and the kernel's firmware loader searches
/lib/firmware, and neither can be told to look elsewhere. Shared libraries are
unaffected — the loader finds them through its own absolute system search path
rather than through this view — and /lib/x86_64-linux-peios/ still resolves,
one level down, which is the shape a foreign binary expects.
/lib64 is not a StrataFS view. On x86-64 it remains the package-owned relative
symlink lib64 -> usr/lib/x86_64-linux-peios, because the psABI dynamic-loader
path must work before any hook can mount the base topology. It is a distinct
object from the /lib view above and is unaffected by what that view maps.
The mount option grammar is:
strata=<stratum>[:<stratum>]...
<stratum> := <path>[+<flag>]...
<flag> := create | ro | am
Paths are absolute. create selects the one stratum that receives creations
and copy-up, ro prevents modification through the merged view, and am
allows the stratum directory itself to be temporarily absent. Literal :,
+, ,, and \ in a path are escaped with \.
Inspecting a stack #
The stratafs command is a read-only inspector. It does not mount, modify, or
clean anything, and it never gains extra authority. Every direct stratum read
runs with your normal access rights. If it cannot see all the information
needed for a complete answer—especially every participant of a protected
merged directory—it fails instead of showing a misleading partial result.
List every StrataFS mount in the current mount namespace, or one exact mount:
Typical output is:
/bin
[0] /lcl/bin+create (present)
[1] /usr/bin+ro (present)
The order is the precedence order. A stratum is present, absent, or
not_directory; the mount itself is also labelled when generically mounted
read-only.
Explaining one path #
Use resolve when you want to know why a name looks the way it does and where
a mutation would go:
The per-stratum states are:
| State | Meaning |
|---|---|
provider | This object currently provides the name. |
participant | This lower directory contributes to the merged directory. |
shadowed | A higher object of the same type wins. |
masked | A provider of another type hides this object. |
absent | This stratum does not hold the path. |
The report also explains write and delete. A write can route in_place,
copy_up, or create; it can instead report erofs, a missing parent, an
unknown immutable-attribute state, or that content I/O follows a symlink.
Deletion identifies the real provider entry to remove and names a lower object
that will resurface. Removing a directory is still conditional on the complete
merged directory being empty.
These are routing answers, not authorisation promises. KACS checks the actual operation against the caller when it is attempted.
For the kernel's answer without the explanation, print the synthetic origin attribute:
A non-directory prints its one real provider path. A merged directory prints every participating real directory in precedence order, one escaped path per line.
Inspecting local state #
The create stratum is deliberately easy to audit. sweep recursively reports
every object in it:
Each result is classified as:
gap: only the create stratum has this path;override: a lower stratum also has it; orshadowed: a higher stratum, or a higher non-directory ancestor, makes it unreachable.
Directories are included. For a directory, override describes structural
presence; corresponding directories still merge. When sweep says
create stratum is empty, the local stratum has no entries to reconcile. The
command never deletes anything—remove a reviewed entry through the merged view
or at its real create-stratum path, according to the result you intend.
To inspect the content of an override:
diff compares the create-stratum object with the first lower default. It
supports regular files and symbolic links, reports type changes, refuses other
object types, and bounds each regular-file read to 16 MiB. Binary files are
reported only as different.
Structured output and status #
list, resolve, and sweep accept --json. JSON is the stable scripting
interface; it includes the same paths, flags, states, object types, and routing
actions. A non-UTF-8 path is represented losslessly in human output but causes
JSON mode to fail rather than substitute a lossy name.
Exit status has probe-friendly meaning:
| Status | Meaning |
|---|---|
0 | Success; for sweep, no entries; for diff, no difference. |
1 | sweep found entries or diff found a difference. |
2 | Usage, visibility, malformed-state, or operational error. |
Where to go next #
For the generic command that creates this and other mounts, read
mount. For how access to every real object is
decided, start with File access.
Package management
Peios / Using Peios / Package management
peipkg is the command that manages software on a running Peios system. It installs packages and the dependencies they need, upgrades them, removes them, and answers questions about what is installed and where it came from.
$ peipkg install nginx
$ peipkg upgrade
$ peipkg list
This page explains what a package is, where packages come from, and the design principle that peipkg holds no authority of its own. It then names every command and points you at the rest of the topic.
What a package is #
Peios distributes software as .peipkg files: signed, self-contained archives. Each one carries a manifest — a name, a version, a target architecture, the other packages it depends on, the packages it conflicts with — and a payload, the files that land on disk when it is installed.
A package is a low-level primitive. It is the unit peipkg installs and tracks. The curated, user-facing concepts that most operators think in — bundles of software, "the web-server role", applications — are built above packages and are out of peipkg's scope: peipkg installs nginx the package; it does not know what a "web server role" is. This topic is about the primitive.
What peipkg does coordinate at the package level is claims: a claim is a single shared name that exactly one installed package may hold, so that two packages offering the same role — for example, registryd and loregd both providing the registry — do not silently collide.
Every installed package is tracked in a private database. peipkg records, for every package, which files it owns — the record that makes a clean removal, a correct upgrade, and the verify check possible.
Where packages come from #
Packages come from repositories: HTTP or HTTPS locations that serve a set of .peipkg files alongside signed indexes describing them. A Peios system is configured with one or more repositories, each one anchored to a signing key the operator has chosen to trust.
peipkg keeps a local, verified copy of each repository's metadata. peipkg refresh updates that copy; peipkg install and peipkg upgrade plan against it. The trust model — how a repository is anchored, how its signing keys rotate, and how unsigned repositories are handled — is covered in Repositories and trust.
A package can also be installed straight from a .peipkg file on disk, with no repository involved. That path trades the repository's trust guarantees for convenience; Installing and removing packages covers what it keeps and what it gives up.
flowchart LR
A["Repository<br/>signed packages + indexes"] -->|"refresh"| B["Cached metadata"]
B -->|"install / upgrade"| C["peipkg<br/>resolve → verify → commit"]
C -->|"file changes"| D["Your system"]
C -->|"operation events"| E["Audit stream"]
peipkg has no authority of its own #
The key design point is that peipkg works differently from package managers on most systems.
peipkg is not a privileged daemon. It is not setuid. It has no service account, no special identity, no broker it asks to do privileged work. It is an ordinary program, and it runs as you — under your token, with your rights.
The question "may I install this?" is therefore not a question peipkg answers. It is the same question Peios asks of any attempt to write a file: it compares your token against the security descriptor on the directory being written. If the security descriptors on /usr, /opt, and the rest say your token may write there, the install succeeds. If they do not, it fails — at the file operation, the same way any unauthorised write fails.
"Who may install software" is therefore not a peipkg setting. It is just the access rules on the system directories — Administrators, by default. To grant someone install rights, you grant them write access to the directories packages land in; to scope what they may touch, you scope those security descriptors. This is the ordinary access-decision machinery, with nothing package-specific layered on top.
One consequence follows directly: a package cannot grant its installer rights the installer did not already have. Any security descriptors a package asks to set on its own files can only be descriptors the caller already had the authority to set. There is no confused-deputy problem, because peipkg holds no authority that a package could misuse.
Every change is a transaction #
An install, an upgrade, a downgrade, a removal — each is a transaction, and each is atomic. There is a single instant, the commit, before which any failure, interruption, or power loss leaves the system exactly as it was, and after which the operation is complete. There is no partially installed intermediate state for a transaction to get stuck in.
Transactions are also reversible. peipkg keeps a history of them, retains the data needed to walk one back, and offers undo and recover to do it. Transactions and recovery covers the model in full.
Every operation is audited #
peipkg records each operation it performs — what was installed, upgraded, or removed, and the outcome — to the Peios audit stream. When a plan contains an action that needs deliberate authorisation, the authorising act itself is recorded too.
These events are a semantic summary: a readable account of what peipkg set out to do. They are not the security boundary. The authoritative record is the kernel's own audit of the actual file operations — and because peipkg runs with no special authority, it cannot suppress that record.
The command surface #
Every command is invoked as peipkg <command> [arguments].
| Command | Does |
|---|---|
install | Install packages, with dependencies, from a repository or a local .peipkg file. |
remove (alias uninstall) | Remove installed packages. |
upgrade | Move installed packages to their newest available version. |
downgrade | Move one package to a specific older version. |
undo | Reverse the most recent transaction. |
claim | Show or change which package holds a claim — a shared name that exactly one package may own. |
refresh | Update the cached metadata of the configured repositories. |
repo | Configure repositories — add, list, remove. |
root | Manage named roots — add, list, remove, show. |
list | List the installed packages. |
info | Show one installed package's details. |
files | List the files a package owns. |
owns | Report which package owns a given path. |
search | Search the configured repositories for a package. |
verify | Check installed files against what was recorded at install. |
history | Show the transaction log. |
recover | Roll back a transaction left pending by an interruption. |
clean | Delete cached metadata for repositories no longer configured. |
One global option sits before the command: --root TARGET makes peipkg operate on a Peios installation other than the running system at /. TARGET can be a literal path — a Peios installation mounted at DIR, the form used by image builders and offline maintenance — or the name of a named root. Named roots are more than a convenience: they are how peipkg keeps components such as the initramfs current. See Named roots for the detail.
The producer side #
peipkg is the consumer half of the Peios packaging story — the tool that runs on a deployed system and consumes packages. It has a counterpart it never shares a process with: the producer tools (peipkg-build, peipkg-repo, peipkg-manager) that turn source into signed .peipkg files and assemble the repositories peipkg fetches from. Building packages and running a repository are a separate job with their own documentation, the Peios Packages guide. This topic is for the operator of a Peios system, not the operator of a build farm.
peipkg has one consumer-side companion of its own: peipkg-compose, a separate binary — not a peipkg subcommand. Where peipkg mutates a running system in place, peipkg-compose builds a fresh, package-owned root directory offline from a declarative manifest, the form image builders use when they assemble a system from nothing. It consumes the same packages peipkg does. See Composing a root.
Where to start #
For the everyday work — putting software on a system and taking it off — read Installing and removing packages.
For keeping a system up to date, and for walking a change back, read Keeping a system current.
To configure where packages come from and how their authenticity is established, read Repositories and trust.
To understand why an interrupted install is always safe, read Transactions and recovery.
To understand how peipkg decides which versions of which packages a request implies — and why some actions require a second, separate authorisation — read Dependency resolution.
To understand how two packages that offer the same shared name settle on a single winner, read Claims.
To inspect what is installed and check that it is intact, read Inspecting and verifying.
For the more specialised work of operating on a Peios installation other than the running one — and how peipkg keeps things like the initramfs current — read Named roots.
For the specialised work of assembling a fresh root offline from a manifest — the image-builder's path, run with the separate peipkg-compose binary — read Composing a root.
Installing and removing packages
Peios / Using Peios / Package management
peipkg install puts packages on the system; peipkg remove takes them off. They are the two commands you reach for most, and they share one flow — peipkg works out the full set of changes, shows it to you, and waits for your approval before touching anything.
$ peipkg install nginx
$ peipkg remove oldtool
Installing packages #
peipkg install <package|file.peipkg>...
Each argument is either the name of a package to fetch from a configured repository, or the path of a local .peipkg file (recognised by its .peipkg suffix). You can mix the two in one command.
Installing a package rarely means installing just that package. peipkg works out everything the request implies — the dependencies the package needs, and the dependencies of those in turn — and presents the whole set. How that set is computed is the subject of Dependency resolution; this page is about the flow around it.
The plan-and-confirm flow #
install, remove, and the commands on Keeping a system current all work the same way. peipkg first produces a plan — the ordered list of changes that satisfy your request — and prints it:
$ peipkg install nginx
the following changes will be made:
install pcre2 10.44
install zlib 1.3.1
install nginx 1.27.4
proceed? [y/N]
Nothing has been downloaded and nothing on the system has changed. peipkg waits for an answer. Anything other than y or yes — including pressing Enter, or end-of-input — is a refusal, and the command exits having done nothing.
Answer y and peipkg carries the plan out as a single transaction: it downloads and verifies every package, then commits the change atomically.
| Option | Effect |
|---|---|
--dry-run | Produce and print the plan, then stop — never prompt, never change anything. |
--yes, -y | Skip the proceed? prompt and apply the plan. |
--no-claim | Install a provider without taking any claim it offers. |
--allow-stale | Proceed although a repository's trust state exceeds its maximum trusted age. See Repositories and trust. |
--claim <names> | Comma-separated claims to force-claim, overriding the current holder(s). |
--claim-all | Force-claim every claim the installed packages provide, overriding incumbents. |
--dangerously-bypass-path-restrictions | Permit packages that declare special_system_package to install outside the payload layout rules. Exempts nothing that has not declared itself special, and never reaches /lcl/policy. Needed only for the handful of packages whose job is to lay down the filesystem structure those rules protect. |
--dry-run is the safe way to see what a command would do. --yes is for scripts and unattended runs — but note that it skips only the routine prompt. A plan that contains an action needing deliberate authorisation will still stop and ask; --yes does not override that. See Elevated authorisation for which actions those are and why.
--claim-all cannot be combined with --claim or --no-claim. Claims — shared names exactly one package may hold — are covered in Claims.
An install can also target a root other than the current one — either explicitly with --root, or because a package declares its own default root. See Named roots for how roots are named and nested.
Installing from a local file #
When an argument is a path ending in .peipkg, peipkg installs that file directly:
$ peipkg install ./nginx-1.27.4.peipkg
This is a raw install, and it differs from a repository install in one specific way: it skips the repository trust layer. There is no signed index to check the file against, no signing key to verify it under, and none of the freshness or rollback protection a repository provides. You are vouching for the file yourself.
Everything else still happens. The package format is fully verified: the archive structure, the manifest, the integrity manifest, and the hash of every payload file are all checked before anything is staged. A corrupt or truncated .peipkg is rejected in the same way as one from a repository. The file's dependencies still resolve normally against your configured repositories — a locally-installed package can pull in repository packages to satisfy what it needs.
A package supplied as an explicit local file takes precedence over any repository's version of the same package, so install ./foo.peipkg installs that file even if a repository offers foo too. In the plan, a local-file operation is marked so the choice is visible:
install nginx 1.27.4 (local file)
In the future, peipkg will be able to consult system policy to decide whether raw installs are permitted at all, and that gate will be configurable. For now a raw install is always allowed; the verification above is what stands behind it.
Removing packages #
peipkg remove <package>...
peipkg uninstall <package>...
remove and uninstall are the same command. Each argument names an installed package; peipkg plans the removal — the files to take off disk — and runs the plan-and-confirm flow described above.
A removal leaves shared directories in place and removes only the files the package owns. peipkg knows which files those are from its database, so a removal is clean and complete.
Removing something that is depended on #
peipkg will not, by default, leave the system inconsistent. If you ask to remove a package that another installed package depends on, the plan is refused: peipkg tells you what still needs it, and stops.
| Option | Effect |
|---|---|
--cascade | Also remove every installed package that depends on the ones named. |
--dry-run | Print the plan and stop. |
--yes, -y | Skip the proceed? prompt. |
--cascade turns that refusal into a wider plan: peipkg computes the full set of packages that would be left with a broken dependency and adds them to the removal. The plan then shows everything that will be removed. Review it before approving, because a cascade can reach further than expected.
$ peipkg remove --cascade libfoo
the following changes will be made:
remove toolA 2.1
remove toolB 1.0
remove libfoo 3.3
proceed? [y/N]
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — or, for --dry-run, the plan was produced. A declined prompt is also 0: nothing failed. |
1 | The operation failed — a package was not found, a dependency could not be satisfied, a download or verification failed, or a file operation was denied. |
2 | A usage error — an unknown command or a malformed option. |
Keeping a system current
Peios / Using Peios / Package management
Keeping a Peios system current is two commands in sequence — refresh to learn what is available, then upgrade to move to it. The other two commands here, downgrade and undo, go the other way: they walk a change back when an upgrade turns out badly.
Refreshing repository metadata #
peipkg refresh [repository]...
peipkg plans against a local, verified copy of each repository's metadata. That copy does not update itself. peipkg refresh is what updates it: for every configured repository — or just the ones you name — peipkg fetches the current signed descriptor and index, verifies them, and replaces the cached copy.
$ peipkg refresh
refreshed "official"
refreshed "internal"
Refresh has two properties worth knowing:
- Repositories are independent. If one repository is unreachable or fails verification, peipkg reports it and carries on with the rest. One bad repository never blocks a refresh of the others.
- A failed refresh changes nothing. If a repository cannot be refreshed, peipkg keeps the metadata it already had. It never falls back to unverified or stale-but-unchecked content. The worst a failed refresh does is leave you on yesterday's view.
What "verified" means here — signing keys, freshness floors, the handling of an unsigned repository — is the subject of Repositories and trust.
Run refresh before an upgrade. An upgrade plans against whatever metadata is cached, so an upgrade without a recent refresh moves you to the newest version peipkg last heard about.
Skip it for long enough and peipkg stops waiting for you: an install, upgrade, or downgrade against a repository whose trust state has passed its maximum trusted age (30 days by default) refreshes that repository itself, and refuses to proceed against one that stays stale — unreachable, or frozen on an unchanging index — unless you pass --allow-stale. The bound and its configuration are covered in Repositories and trust.
Upgrading #
peipkg upgrade [package]...
With no arguments, upgrade considers every installed package and moves each one that has a newer available version forward to it. With one or more package names, it upgrades only those — and still pulls in any new dependencies they need.
$ peipkg refresh && peipkg upgrade
the following changes will be made:
upgrade zlib 1.3.1 -> 1.3.2
upgrade nginx 1.27.4 -> 1.27.5
proceed? [y/N]
upgrade uses the same plan-and-confirm flow as install — peipkg shows the full set of changes and waits for approval — and the same options:
| Option | Effect |
|---|---|
--dry-run | Print the plan and stop. A good way to preview what an upgrade would move. |
--yes, -y | Skip the proceed? prompt. |
--no-recurse | Confine the upgrade to the current root only — disable the cascade into nested named roots. |
--allow-stale | Proceed although a repository's trust state exceeds its maximum trusted age. Warned and audited. |
By default, when named roots are configured, upgrade cascades: it reconciles the current root and every named root nested under it, each as an independent continue-on-error transaction with its own summary. --no-recurse disables that and upgrades the current root alone. See Named roots for the named-roots model.
If there is nothing to do — every package already at its newest version — peipkg says so and exits.
Downgrading #
peipkg downgrade <package> <version>
downgrade moves one package to a specific older version. You name the package and the exact version you want:
$ peipkg downgrade nginx 1.27.4
Older versions are not in a repository's active index — that index lists current versions only. They live in its archive index, which peipkg fetches on demand when you ask for a version that is not current. The package must still exist in some configured repository's archive for the downgrade to be possible.
A downgrade is treated as a deliberate move. Going backward — onto a version that may have known issues a newer one fixed — is one of the actions peipkg flags for explicit authorisation: beyond the routine proceed? prompt, peipkg asks you to authorise the specific downgrade, and --yes does not stand in for that answer. See Elevated authorisation for the full set of actions that work this way.
downgrade accepts --dry-run, --yes, -y, and --allow-stale with the same meaning as elsewhere.
Undoing the last change #
peipkg undo
undo reverses the most recent committed transaction. If that transaction installed a package, undo removes it; if it upgraded, downgraded, or removed packages, undo restores each one to the version it had before.
$ peipkg undo
undoing transaction 47 (upgrade nginx, zlib)
the following changes will be made:
downgrade nginx 1.27.5 -> 1.27.4
downgrade zlib 1.3.2 -> 1.3.1
proceed? [y/N]
Be precise about what undo is. It is not a rollback of committed state — the previous transaction happened and stays in the history. undo computes the inverse of that transaction and applies it as a new transaction of its own. The history grows; it does not rewind. That new transaction can itself be undone, and so on.
Because restoring an older version is a backward move, undo carries the same explicit-authorisation requirement as downgrade for any package it walks back. It accepts --dry-run and --yes, -y.
To undo something other than the most recent transaction, or to see the history undo works against, use peipkg history — and revert a specific package directly with downgrade.
The routine cycle #
For day-to-day maintenance the loop is:
$ peipkg refresh # learn what is available
$ peipkg upgrade --dry-run # preview the move
$ peipkg upgrade # apply it
downgrade and undo are the recovery commands — use them when an upgrade has brought in a change you want to remove. Every one of these commands is a transaction, so every one of them is atomic and itself reversible.
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — including a dry run, a plan with nothing to do, and a declined prompt or authorisation (nothing failed). |
1 | The operation failed — a repository could not be refreshed, a repository's trust state exceeded its maximum age and a forced refresh could not clear it, resolution or a download or verification failed, a root in an upgrade cascade failed, there is no committed transaction to undo, or a command's arguments were wrong (downgrade without a package and version, an unparsable version, a malformed option). |
2 | A usage error before any command ran — no command, an unknown command, or a malformed global option (including a --root reference that does not resolve). |
Repositories and trust
Peios / Using Peios / Package management
A repository is where packages come from: an HTTP or HTTPS location that serves .peipkg files alongside signed indexes describing them. A Peios system has zero or more repositories configured, and the peipkg repo commands manage that configuration.
Beyond configuration, this page covers trust: establishing that the packages a repository serves are genuinely the ones its operator published, and not something a network attacker or a compromised mirror substituted.
What a repository serves #
At its base URL a repository serves three signed documents:
- A descriptor — the repository's identity: the set of signing keys it uses, each with a status. This is the root of trust for everything else.
- An active index — the catalog of current package versions: names, versions, dependencies, hashes, and download locations.
- An archive index — the catalog of older versions, kept so a downgrade can reach them. peipkg fetches it only on demand.
The descriptor and the indexes each carry a detached signature. peipkg verifies all of them; an unverifiable document is discarded, never used.
Adding a repository #
peipkg repo add <name> <base-url> --anchor <fingerprint>
repo add does two things: it records the repository in your configuration, and it runs the trust ceremony that anchors it.
$ peipkg repo add official https://pkgs.peios.org \
--anchor ef86709c4b1d8a02e5f3c719d640aa8b7c2e9105f8d3b6470a1c2e9d8b5f3a04
added repository "official"
The --anchor value is a signing-key fingerprint, and it is the part of the command that establishes trust. peipkg cannot tell you whether a repository is genuine; only you can, by obtaining its fingerprint through a channel you trust (the project's website over HTTPS, a colleague, a printed reference) and supplying it here. The anchor is your out-of-band statement that the key is trusted.
Given an anchor, the ceremony runs:
- peipkg fetches the repository's descriptor and its signing keys.
- It checks the fetched keys against the anchors you supplied — a fingerprint must match bit for bit.
- Only if the descriptor's signature verifies against an anchored key does peipkg accept it and record the trust state.
If the fetched descriptor does not verify against any anchor, repo add fails and leaves nothing behind — no half-added repository, no configuration file. You can supply --anchor more than once to trust several keys at first contact.
| Option | Default | Effect |
|---|---|---|
--anchor FINGERPRINT | — | A signing-key fingerprint to trust. Repeatable. |
--priority N | 50 | Resolution priority — a lower number wins. See below. |
--policy required|optional | required | Whether a valid signature is required. See Signature policy. |
--min-index-version N | 0 | A freshness floor for the index. See Freshness. |
--max-trusted-age-days N | 30 | How stale the repository's trust state may grow before operations force a refresh. See Freshness. |
--max-index-staleness-days N | 90 | How old the index's own generated_at may be before operations force a refresh. See Freshness. |
--insecure | off | Permit a plain http:// base URL. |
--insecure exists for a repository on a trusted local network with no TLS. It lowers transport confidentiality and integrity; the package signatures still protect authenticity, but prefer https:// whenever it is available.
Adding a repository your system already carries #
An image can ship a repository's configuration — its base URL, its policy, and its trust anchors — as a .repo file in /conf/peipkg/ (stored at /lcl/conf/peipkg/, which is what that view exposes). A Peios installation medium does exactly that for the offline repository it carries.
Configuration alone is not trust. peipkg does not trust a configured repository on sight: until the ceremony has run there is no recorded trust state, and every operation skips the repository with a warning rather than quietly using it. The decision to trust a key is yours, never a default.
What a baked-in .repo file changes is only where the anchor comes from. It arrived with the image rather than being typed at the prompt, which is still out-of-band — it did not come from the repository it authenticates. So the ceremony can run against it without you retyping a 64-character fingerprint that is already on disk:
$ peipkg repo add peios-medium
added repository "peios-medium"
Given a name and nothing else, repo add reads that repository's existing configuration and runs the same ceremony as the two-argument form. It is still an explicit act — nothing here happens on its own.
Two differences from the full form are worth knowing:
- It refuses a configuration with no
trust_anchorsunder therequiredpolicy, because there would be nothing to verify the descriptor against. - A failed ceremony leaves the
.repofile alone. The two-argument form wrote that file and removes it again on failure; here it came from somewhere else, and deleting another party's configuration because a ceremony failed would turn a retryable problem — a medium not mounted yet, a repository not published — into lost settings.
How trust survives key rotation #
A repository operator will, over time, rotate signing keys — retiring an old one, bringing a new one into use. If trust were pinned permanently to the fingerprint you first anchored, every rotation would break every consumer.
Rotation does not break trust, because the descriptor carries the keys. Each key in the descriptor has a status:
| Status | Meaning |
|---|---|
active | In current use for signing. |
transitioning | Being phased in or out — honoured until its stated expiry. |
revoked | Withdrawn — never honoured again. |
Each time peipkg refreshes a repository, it verifies the new descriptor against the keys in the descriptor it already trusts, and then adopts the new descriptor's key set as the current trust state. A rotation that is itself signed by a still-trusted key propagates automatically: you anchored once, and the chain carries forward from there without you re-anchoring. A revoked key is dropped and never accepted again.
The anchor therefore matters only at first contact. After that, trust is a chain, and each refresh extends it.
Signature policy #
--policy chooses how strict peipkg is about signatures for this repository.
required(the default) — every descriptor and index must carry a valid signature from a trusted key. An unverifiable document is rejected. This is the correct setting for any repository reached over a network.optional— combined with no trust anchors, this puts the repository into unsigned mode: peipkg fetches and uses its metadata and packages without cryptographic verification.
Unsigned mode is a deliberate escape hatch for a scratch repository on a build host, or a local mirror you fully control. Every operation that touches an unsigned repository prints a warning:
peipkg: warning: repository "scratch" is unsigned — its metadata and
packages are not cryptographically verified
In unsigned mode, only the transport stands between you and a substituted package. Do not use it for anything reachable from an untrusted network.
Priority #
--priority is a number — lower is stronger, default 50 — that decides which repository wins when more than one offers the same package. If both an official repository at priority 10 and an internal one at priority 20 carry nginx, the resolver takes the official build. Priority does not restrict anything; it only breaks ties. Its full role in version selection is covered in Dependency resolution.
Priority also feeds one of the safety checks there: a package from a lower-priority repository that tries to displace a package installed from a higher-priority one is flagged for explicit authorisation, so a low-trust repository cannot quietly take over a package you got from a trusted one.
Freshness — defeating a stale-repository attack #
A signature proves a document is authentic; it does not prove the document is current. An attacker who cannot forge a signature can still serve you a genuine, correctly-signed, but old index — one from before a security fix was published — and a naive consumer would accept it.
peipkg closes that gap by tracking, per repository, the highest index version it has ever seen, and refusing any index that goes backward. Once you have seen version 5 of an index, version 4 — however well signed — is rejected. A repository can only ever move forward.
--min-index-version N lets you set that floor explicitly, out of band: if you know the current index is at least version N, anchoring that number means peipkg will reject anything older even on the very first fetch, before it has a history to compare against.
Going backward is one half of the attack; standing still is the other. A frozen repository — one that serves the same, correctly-signed index forever — never trips the rollback check, yet it can hold you on a view from before a security fix indefinitely. Against that, peipkg tracks when each repository last refreshed with progress and enforces a maximum trusted age: 30 days by default, tunable per repository with max_trusted_age_days in its .repo file or --max-trusted-age-days at add time.
When an install, upgrade, or downgrade finds a repository's trust state older than its maximum, peipkg refreshes that repository first. If it cannot — the repository is unreachable, or it refreshes without progressing — the operation is refused rather than planned against outdated metadata. Passing --allow-stale to the operation overrides the refusal; the override is warned about and recorded in the audit stream. A maximum above 180 days draws a warning on every operation, because a bound that loose effectively disables the check.
There is a third way to stand still, and it needs its own check. Maximum trusted age asks how long since I last refreshed successfully — and a repository can keep that answer flattering forever by bumping its index version on every publication while stamping the index with an ancient generated_at. Every refresh looks like progress; the metadata never actually moves. So peipkg separately enforces a maximum index staleness measured from the index's own generated_at: 90 days by default, tunable per repository with max_index_staleness_days in its .repo file or --max-index-staleness-days at add time.
The two are deliberately independent, and raising one does not widen the other — a max_trusted_age_days of 180 still leaves the 90-day staleness window in place. An index past that window triggers a refresh before any install proceeds, and the same --allow-stale override applies, with the same warning and audit record. A staleness bound above 365 days draws a warning on every operation.
Listing and removing repositories #
peipkg repo list
peipkg repo remove <name>
repo list prints the configured repositories — name, base URL, priority, signature policy. It accepts --json.
$ peipkg repo list
official https://pkgs.peios.org priority=10 required
internal https://pkg.corp.example priority=20 required
repo remove deletes a repository's configuration and the trust state peipkg recorded for it. Packages already installed from that repository stay installed — removing a repository controls where future packages come from; it does not remove past ones. Those packages simply no longer have a source for upgrades until you add a repository that carries them again.
Where the configuration lives #
Each repository is one file: /lcl/conf/peipkg/<name>.repo, in flat TOML.
= "https://pkgs.peios.org"
= 10
= "required"
= ["ef86709c4b1d8a02e5f3c719d640aa8b7c2e9105f8d3b6470a1c2e9d8b5f3a04"]
The files are hand-editable, and editing one is a legitimate way to configure a repository — a trust anchor written into the file is you supplying that anchor out of band. peipkg repo add is the convenient front end: it runs the ceremony and writes the file for you. Who may edit these files is, like everything else on Peios, the security descriptor on /lcl/conf/peipkg/.
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — the repository was added, listed, or removed. |
1 | The operation failed — the trust ceremony failed (repo add backs out and leaves nothing behind), or the subcommand was missing, unknown, or given the wrong arguments (a missing repo add <name> <base-url>, a malformed option). Removing a name that is not configured is not a failure. |
2 | A usage error before any command ran — no command, an unknown command, or a malformed global option. |
Transactions and recovery
Peios / Using Peios / Package management
Every change peipkg makes — an install, an upgrade, a downgrade, a removal — is a transaction. A transaction is atomic: it either happens completely or not at all. There is no state in which a package is half-installed, and no failure, signal, or sudden power loss that can leave one there.
This page explains how that guarantee is built, what an interrupted run leaves on disk, and the two commands — recover and history — that exist because of it.
The three phases #
Every operation moves through the same three phases.
flowchart LR
P["Plan<br/>read-only, no lock"] -->|"you approve"| S["Stage<br/>lock held; fetch + verify"]
S --> C["Commit<br/>the atomic flip"]
C --> D["Done"]
P -.->|"abort"| X["Nothing changed"]
S -.->|"abort / crash"| X
Plan. peipkg reads your request, reads the installed set, reads the cached repository metadata, and computes the ordered list of changes — or rejects the request. This phase is entirely read-only and takes no lock. It is why --dry-run and the query commands never block, even while another transaction is running, and why a plan can always be abandoned for free.
Stage. Once you approve the plan, peipkg takes a single-writer lock — only one transaction touches the system at a time — and prepares everything. It downloads every .peipkg in the plan and verifies all of them before staging any one of them, so no package's contents can influence another's verification. Verified payloads are written into a staging area, each file hash-checked as it lands. Nothing the system uses has changed yet.
Commit. peipkg moves the staged files into place and records the new package state. This is the phase that changes the system.
The one instant that matters #
Within the commit there is a single instant — the moment peipkg records the new state in its database — that divides the entire operation in two:
- Before it: any failure, any signal, any power loss rolls everything back. The staged files are discarded, any displaced files are put back, and the system is exactly as it was.
- After it: the operation is complete and durable.
There is no third outcome. A transaction is never "partly applied". The rest of this page describes the machinery that delivers this guarantee.
Backups make rollback free #
When peipkg replaces or removes a file, it does not overwrite or delete it. It renames the old file aside — to a sibling name in the same directory — and puts the new file in place. The old contents are untouched, just under a different name.
Rolling back is then simply renaming everything back. No data is copied, no contents are reconstructed; the rollback is the same cheap rename operation in reverse. This is why a failed transaction recovers to a byte-exact prior state.
Those set-aside files also outlive a successful commit for a while — retained so that an undo of a recent transaction is fast and needs no network. They are cleaned up automatically as they age out.
What an interruption leaves behind #
If a transaction is interrupted mid-stage or mid-commit, you may find files with these names near where a package was being installed:
| Name | Is |
|---|---|
<name>.peipkg-staged-<id> | An incoming file that had been staged but not yet moved into place. |
<name>.peipkg-backup-<id> | A file that had been renamed aside to make room — the backup. |
These names are deliberate. They have no leading dot, so they are visible rather than hidden — you are meant to find them and understand them. The <id> is the transaction number; look it up with peipkg history to see which operation left it.
You do not clean these up by hand. peipkg knows about them and resolves them itself — see recover, next.
Recovering an interrupted transaction #
peipkg recover
When a transaction is interrupted before it commits, peipkg records it as pending. The next time peipkg starts any transaction it checks for a pending one first and rolls it back automatically before doing anything else — so in normal use recovery just happens, and you never see it.
peipkg recover runs that step on demand. Use it when a transaction was interrupted and you want the system put right immediately, without waiting for the next install or upgrade.
$ peipkg recover
recovered: the interrupted transaction was rolled back
Recovery only ever rolls back. A transaction that was interrupted after its commit instant is already complete — there is nothing pending and nothing to recover. Recovery deals exclusively with the "before the instant" case, and it always resolves it the same way: back to the prior state.
If there is no pending transaction, recover says so and exits cleanly.
Across more than one root #
When an operation spans more than one named root, those roots commit as a unit under a single cross-root transaction, and undo reverses all participating roots together. recover reconciles pending cross-root transactions across every reachable root: a torn commit is rolled back, or — if it had already passed the commit instant — rolled forward to completion. See Named roots for how multiple roots come about.
The transaction log #
peipkg history
history prints the transactions peipkg has carried out, most recent first — each with an id, a timestamp, its state (committed, rolled-back, or pending), and a short summary.
$ peipkg history
49 2026-05-19T14:02:10Z committed upgrade nginx, zlib
48 2026-05-19T09:31:55Z committed install nginx
47 2026-05-18T22:14:03Z rolled-back install brokenpkg
| Option | Effect |
|---|---|
-n N | Show at most N transactions. -n 0 shows all of them. The default is 20. |
--json | Emit JSON. |
The history is what undo reads to find the most recent transaction, and what ties a stray *.peipkg-backup-<id> file back to the operation that created it.
Configuration files on upgrade #
Upgrading a package raises a question for any configuration file under /etc/ that the package owns: the new version ships a new default, but you may have edited the old one. peipkg decides per file, by comparing the file on disk against the hash it recorded at install:
-
Unchanged since install — peipkg replaces it with the new default. You wanted the package's settings, and you get the current ones.
-
Edited since install — peipkg keeps your file untouched and writes the new default beside it as
<name>.peipkg-new. The upgrade report notes it:peipkg: warning: /etc/nginx/nginx.conf has been modified since install — keeping it; the new default was written to /etc/nginx/nginx.conf.peipkg-new
Your edits are never silently discarded, and the new defaults are never silently lost — you are simply told the two diverged and left to merge them when you choose.
Side effects run after commit #
A few packages need a system-wide step after their files are in place — refreshing the shared-library cache, the kernel-module map, or the manual-page index. peipkg runs those steps after the commit instant, once per transaction.
Because they run after the operation is already complete and durable, a side-effect step that fails is reported as a warning, not a failure. The transaction stands; the step is one that corrects itself the next time it runs. An install is never rolled back over a stale cache.
Why this shape #
The pay-off of the three-phase model is concrete:
- An install interrupted by a crash or a power cut never leaves a broken package — it leaves either the old state or the new one.
- The plan you approve under
--dry-runis the plan that executes — resolution is read-only and deterministic. - Queries and dry-runs never wait on an in-flight transaction, because only staging and commit take the lock.
- Every transaction is reversible, by
undofor a recent one ordowngradefor a specific package.
The model rests on one principle: all the fallible work — downloading, verifying, staging — happens before the commit instant, so the commit itself is the only point at which the system changes state.
Where to go next #
For the commands that reverse a committed transaction, read Keeping a system current.
For how a transaction that spans several named roots commits and recovers as a unit, read Named roots.
For the events every transaction emits, read Auditing.
Dependency resolution
Peios / Using Peios / Package management
When you ask peipkg to install nginx, the request does not yet describe the full work. nginx needs other packages; those need others; some version of each has to be chosen; everything has to be installed in an order where a package's dependencies come before it. Turning the short request into that concrete, ordered plan is resolution, and it is the first thing every change command does.
Resolution is a pure calculation #
peipkg resolves from metadata alone — the package descriptions in the cached repository indexes and in installed packages' manifests. It does not download any .peipkg files to work out a plan. Downloading happens later, only once a plan is approved.
That makes resolution a pure calculation over three inputs — your request, the set of installed packages, and the set of available packages — and gives it a property worth relying on: it is deterministic. The same inputs always produce the same plan. The plan you inspect with --dry-run is the plan that will execute; nothing is re-decided between previewing and applying.
If a request cannot be satisfied — a dependency that no repository can provide, two requirements that contradict — resolution rejects it, with an explanation, before anything is fetched or changed.
The relationships between packages #
A package's manifest declares how it relates to others. Four relationships drive resolution.
Dependencies. A package can require other packages, each by a version range. peipkg pulls every dependency into the plan, and their dependencies in turn, until the request is closed.
Conflicts. A package can declare that it cannot coexist with another. If a plan would put two conflicting packages on the system at once, resolution rejects it.
Provides. Several packages can advertise the same capability — a virtual name that is not itself a package. A dependency written against that name is satisfied by any package that provides it. This is how "needs a mail transport agent" can be met by whichever one you actually install.
You can ask for a virtual name directly, too: peipkg install coreutils works even when nothing is named coreutils, and installs whichever package provides it. Because you asked for one name and got a package with another, peipkg says which one it chose. Upgrades and removals are the exception — those name a package you already have, so they match by name only and never substitute one package for another.
Replaces. A package can declare that it supersedes another — the usual case being a rename, or a merge of two packages into one. Installing a package that replaces another causes the replaced package to be removed as part of the same plan.
provides has a stronger cousin: a claim, a single shared name that exactly one package may hold at a time. Where any number of packages can advertise the same provides name at once, a claim has one holder — see Claims.
A dependency can also be routed into a different root, written Depends: foo IN <root>, so that a whole root can be composed through the dependency graph — see Named roots.
Choosing a version #
When more than one version of a package could satisfy the plan, peipkg chooses by a fixed rule:
- Prefer the highest version that satisfies every constraint on that package.
- When two repositories offer the same version, prefer the one with the stronger (lower-numbered) priority — see Repositories and trust.
When the candidates are competing to fill a virtual name rather than being versions of one package, "highest version" means the version each one advertises for that name, not its own. Two packages providing coreutils are unrelated software numbered on unrelated scales, so peipkg compares what each offers the role — otherwise the role would go to whichever project numbers its releases higher. If everything above still ties, package name order settles it, so the answer never depends on the order packages happened to be read in.
For an upgrade, the installed version anchors the search: peipkg looks for something newer and stays put if there is nothing newer to move to.
Optional dependencies are never chosen automatically. A package can suggest companions that are useful but not required; peipkg surfaces those as suggestions and leaves the decision to you. A plan only ever contains what is genuinely needed plus what you asked for.
The plan #
The output of resolution is the ordered list of operations you see at the proceed? prompt — installs, upgrades, downgrades, and removals, sequenced so every package's dependencies are in place before it.
A removal gets one extra step. peipkg first computes the reverse-dependency closure — everything that would be left needing a package you are removing — and either refuses the plan or, with --cascade, widens it to include them. That logic is covered in Installing and removing packages.
Resolution is also bounded. A dependency graph cannot be crafted to make peipkg search forever; the resolver works under a hard cap and gives up cleanly rather than hanging if a graph is pathological.
Elevated authorisation #
Most of a plan is routine, and the single proceed? prompt covers it. A plan can also contain an action that a routine yes should not cover — one you must review and approve individually, on its own.
peipkg detects three such actions and, for each one in a plan, asks a separate question:
this operation requires elevated authorisation:
nginx would move backward from 1.27.5 to 1.27.4
authorise this specific action? [y/N]
The three actions that trigger it:
| Action | Why it is elevated |
|---|---|
| A downgrade | Moving a package backward can reintroduce a problem a newer version fixed. It should be a conscious choice, never a side effect of some larger plan. |
A foreign replaces | A package from a lower-priority repository using replaces to displace a package you installed from a higher-priority one. Left unguarded, a low-trust repository could quietly take over a package you trusted a better source for. |
A low-trust provides | A package from a low-trust repository advertising a virtual capability in a way that shadows a package from a more-trusted source — satisfying a dependency with its own build instead of the one you would expect. |
Two things make this prompt different from the routine one:
--yesdoes not satisfy it.--yesskips the routineproceed?prompt; it has no effect on an elevated-authorisation question.- It fails closed. With no terminal to answer on — an unattended script, a pipeline — there is no answer, so the action is not authorised and the operation is cancelled. An elevated action is never authorised by default.
Each elevated action is presented and authorised on its own; approving one does not approve the next. And the authorising act is itself written to the audit stream — the record shows not just what was done, but that it was specifically authorised and what was authorised.
If an elevated action is not one you want, the plan as a whole is the thing to reconsider: where the package is coming from, whether the repository priorities are right, whether the downgrade is really what you meant.
Where to go next #
For the flow that presents and applies the plans resolution produces, read Installing and removing packages.
For the single-holder extension of provides, read Claims.
For the repository priorities and trust states resolution consults, read Repositories and trust.
Claims
Peios / Using Peios / Package management
Some filesystem names can be provided by more than one package. Two registry daemons — loregd and an alternative — both install a working binary, but only one of them can own /usr/bin/registryd. peipkg calls that shared name a claim, and it owns the machinery that decides which package holds it.
Three words are used precisely on this page:
- A claim is a single shared filesystem name that many installed packages may be able to answer, but exactly one may hold at a time.
- An eligible provider is an installed package able to answer a given claim.
- The holder is the one package that currently answers it.
(This is unrelated to token claims — the security-token attribute concept in the identity docs. The same word names a different subsystem.)
What a claim is #
A claim materialises as a symlink on disk. The link lives at the claim path — the shared name, such as /usr/bin/registryd — and points at a file inside the holder's payload, the target, such as /usr/sbin/loregd. Ask the system for the shared name and you reach whichever provider currently holds it.
/usr/bin/registryd -> /usr/sbin/loregd
The claim symlink is owned and managed by peipkg. It is not shipped inside any package's payload; no provider installs it, and removing a provider does not remove it out from under peipkg. peipkg creates, repoints, and tears down the link as part of the transactions that install, remove, grant, and revoke.
The two sides of a claim are declared in package manifests:
- A provider package declares the target file it offers for a claim — the real binary the shared name would resolve to.
- A consumer package references the claim path where it expects the shared name to appear.
peipkg joins the two. A provider can offer a target for a claim, a consumer can depend on the name being present, and peipkg mediates between them, keeping exactly one provider wired to the shared path at any moment.
Claims and provides/replaces #
A claim is the "exactly one owner of a shared name" extension of the provides and replaces relationships covered in Dependency resolution. There, several packages can advertise the same virtual name and any one of them satisfies a dependency written against it — that mechanism answers "is something here that provides this?". A claim goes one step further: it answers "which single provider owns this concrete filesystem name right now?", and enforces that the answer is never more than one. Provides establishes that a name can be answered; a claim decides which package answers it on disk.
Auto-claim on install #
Installing an eligible provider auto-claims every claim it provides that is currently unheld. If no package yet holds registryd, installing loregd makes loregd the holder and materialises the link as part of the same transaction — you get a working shared name without a second step.
The rule is strictly unheld-only. Auto-claim never overrides a claim that is already held by another package. Install a second registry daemon while loregd holds registryd and the newcomer is installed as an eligible provider but takes nothing; loregd stays the holder. Reassigning a held claim is always a deliberate act — see the claim command below, or the install flags that force it.
Install flags #
peipkg install accepts flags that override the default auto-claim behaviour for the packages in that install:
| Option | Effect |
|---|---|
--no-claim | Claim nothing. Install the provider(s) without taking any claim, even ones that are currently unheld. |
--claim <names> | Force-claim the named claims (comma-separated), overriding the current holder of each. |
--claim-all | Force-claim every claim the installed packages provide, overriding incumbents. |
Two combinations are hard errors:
--claim-alltogether with--claim.--claim-alltogether with--no-claim.
--claim and --claim-all are how you take a claim that is already held during an install; without them, an install only ever fills claims that are empty.
The claim command #
peipkg claim inspects a claim and reassigns its holder. It has three forms.
peipkg claim <claim>
peipkg claim <claim> grant <package>
peipkg claim <claim> revoke
Report status. With just a claim name, peipkg prints the current state of the claim: the current holder, the materialised links shown as path -> target, and the installed eligible providers.
$ peipkg claim registryd
holder: loregd
links:
/usr/bin/registryd -> /usr/sbin/loregd
eligible providers:
loregd
altregd
Grant. grant <package> makes an installed eligible provider the holder. peipkg atomically repoints all of the claim's links to that package's targets — every path the claim covers moves together, or none does. The named package must be an installed eligible provider for the claim.
$ peipkg claim registryd grant altregd
Revoke. revoke removes the grant. The claim becomes unheld and its links are torn down. peipkg does not automatically promote another provider — a revoked claim has no holder until you grant one.
| Option | Effect |
|---|---|
--yes, -y | Skip the confirmation prompt. Applies to grant and revoke. |
grant and revoke each run as a standalone transaction. Like every other peipkg change they appear in history, can be reversed with undo, and are fully auditable — each emits a claim event to the audit stream. A reassignment is never a silent, unrecorded edit to a symlink; it is a first-class, reversible operation.
What happens on uninstall #
Uninstalling the current holder auto-withdraws the claim. The holder is going away, so peipkg tears down its links and the claim becomes unheld as part of the removal.
peipkg does not auto-promote another provider in its place — an automatic promotion would be the kind of silent reassignment claims exist to prevent. Instead it surfaces the remaining eligible providers and hands you a ready-to-run command to reassign the claim yourself:
$ peipkg remove loregd
...
claim 'registryd' is now unheld. eligible providers: altregd
to reassign it, run:
peipkg claim registryd grant altregd
If the holder was the only eligible provider, the claim is left unheld with nothing to promote, and any consumer relying on the shared name will find it absent until a new provider is installed.
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — the status was reported, or the grant or revoke was applied (a declined prompt is also 0: nothing failed). |
1 | The operation failed — the claim has no eligible provider, the named package is not an eligible provider, or a named package or claim is not installed. |
2 | A usage error — an unknown subcommand (the only subcommands accepted after the claim name are grant and revoke) or a malformed option. |
Inspecting and verifying
Peios / Using Peios / Package management
The commands on this page change nothing. They report what is installed, look up a package in the repositories, check that installed files are still intact, and tidy the metadata cache. Use them to see what is on a system and to check that it is still as it should be.
Listing what is installed #
peipkg list
list prints every installed package — name, version, and architecture.
$ peipkg list
nginx 1.27.5 x86_64
pcre2 10.44 x86_64
zlib 1.3.2 x86_64
With --json it emits the same set as JSON, with each package's origin repository included.
Showing one package's details #
peipkg info <package>
info prints the full record of one installed package: its version and architecture, the repository it came from (or (local file) if it was installed from a .peipkg directly), when it was installed, and — from its manifest — its description, license, and homepage.
$ peipkg info nginx
name: nginx
version: 1.27.5
architecture: x86_64
origin: official
installed: 2026-05-19T14:02:10Z
description: HTTP and reverse proxy server
license: BSD-2-Clause
homepage: https://nginx.org
Listing the files a package owns #
peipkg files <package>
files prints every filesystem object the package owns — the files, directories, and symlinks that were placed by its install and are tracked against it. This is the record peipkg uses to remove the package cleanly and to verify it.
$ peipkg files zlib
/usr/lib/libz.so.1
/usr/lib/libz.so.1.3.2
/usr/include/zlib.h
Finding which package owns a path #
peipkg owns <path>
owns is the reverse lookup: given a path, it reports which installed package placed it.
$ peipkg owns /usr/lib/libz.so.1
zlib
If no installed package owns the path, owns says so and exits non-zero. A path that nothing owns is either not part of any package or was created outside peipkg.
Searching the repositories #
peipkg search <term>
search looks through the configured repositories' active indexes for packages whose name or description contains the term, case-insensitively. It searches what is available, not what is installed — it is how you find a package before installing it.
$ peipkg search proxy
nginx 1.27.5 [official] HTTP and reverse proxy server
haproxy 2.9.7 [official] Reliable, high-performance TCP/HTTP load balancer
search reads the cached repository metadata, so run peipkg refresh first if you want it to reflect the latest catalog. A repository with no usable cached metadata is skipped with a warning rather than failing the search. --json emits the matches as JSON.
Verifying installed files #
peipkg verify [package]...
verify checks that what is on disk still matches what was recorded when each package was installed. For every file a package owns it checks:
- a regular file — that it is present and its content still hashes to the recorded value;
- a symlink — that it is present and still points where it was recorded to point;
- a directory — that it is still present and still a directory.
With no arguments, verify checks every installed package. With package names, it checks only those.
$ peipkg verify nginx
nginx: /etc/nginx/nginx.conf has been modified since install
verify: 1 problem(s) found
A reported file is not necessarily a fault. A configuration file you edited on purpose will show up — verify is telling you the file diverged from the package's version. verify reports; it does not judge intent and it does not change anything.
If every checked file is intact, verify says so and exits 0. If anything diverged, it lists each problem and exits non-zero — which makes it usable as a check in a monitoring script.
Cleaning the metadata cache #
peipkg clean
peipkg keeps a verified copy of each repository's metadata in a local cache. When you remove a repository, its cached metadata is no longer needed. clean deletes only those orphaned cache files — the ones belonging to repositories that are no longer configured.
$ peipkg clean
removed 2 orphaned cache file(s)
clean never touches the metadata of a repository that is still configured, so it cannot leave you needing a refresh. It is pure housekeeping, and it is optional; run it whenever you like.
Exit status #
| Code | Meaning |
|---|---|
0 | The command succeeded. For verify, every checked file was intact. |
1 | The command failed — a named package is not installed, a path is owned by nothing, or, for verify, at least one file had diverged. |
2 | A usage error — an unknown command or a malformed option. |
Named roots
Peios / Using Peios / Package management
Every peipkg command runs against a root — a subtree it treats as a complete Peios installation. By default that root is /, the running system. The --root DIR global option points peipkg at a different one: an image mounted at DIR, an offline system under maintenance, a tree being built.
A single bare path is enough when there is exactly one alternate tree and you always spell it out in full. It stops being enough when a system is several cooperating trees that are built and kept current together. Peios's dynamic initramfs is the case that motivates this page: a real system root at / and an initramfs image at boot/initramfs, both assembled from ordinary packages, both upgraded by the same package manager. Passing --root boot/initramfs on every command that touches the image — and remembering that path everywhere — is the friction named roots remove.
So --root generalises. A root can register other roots under it by name, reference them by that name instead of a path, compose those names by dotting, install a dependency into a different root than its depender, and upgrade every nested root in one command. The mechanism is deliberately general — the initramfs is one arrangement it expresses, not a special case wired into the tool.
What a named root is #
A named root is a registered name for a subtree, recorded in the registry of the root it lives under. The name initramfs bound to the path boot/initramfs is a named root of /: it says "within this root, the name initramfs means the subtree at boot/initramfs, and that subtree is itself a root."
Two ideas follow from that.
Every root has its own registry. The bindings a root knows about are its own. / may register initramfs; what initramfs registers is recorded inside initramfs, not in /. Paths in a registry are stored relative to the root that owns them, so a registry travels with its tree — an image built in one place and mounted in another resolves the same way.
References are resolved from the current root. The current root is whatever --root points at (or / when it is omitted). A reference is resolved by starting there and walking its registry.
Referencing by name #
Once initramfs is registered under /, these two commands target the same tree:
$ peipkg --root boot/initramfs install live-boot
$ peipkg --root initramfs install live-boot
The second names the root instead of spelling out its path. The name is looked up in the current root's registry and resolved to the bound path.
Dotted composition #
Names compose by dotting. initramfs.subroot resolves left to right: from the current root, walk into initramfs's registry, then from there into subroot. Each dot is one more step outward through one more registry.
$ peipkg --root initramfs.subroot install ...
Every dotted segment must match the grammar [a-z0-9][a-z0-9_-]* — a lowercase-alphanumeric lead followed by lowercase alphanumerics, hyphens, and underscores. A segment that names no registered root is a hard error, not a silent miss. A registry arrangement that loops back on itself is detected and reported as a resolution cycle rather than followed forever.
Path or name: how --root decides #
--root accepts either form and tells them apart by a single rule:
- Any value containing
/is a filesystem path and is used exactly as before —--root boot/initramfs,--root /mnt/image,--root ./tree. This is unchanged behaviour. - A bare dotted identifier is a named reference —
--root initramfs,--root initramfs.subroot— resolved through the registries as above.
The / is the discriminator. If you mean a literal directory, include a slash (even ./name); if you mean a registered name, use the bare identifier.
The root command #
root manages named roots. Every subcommand operates on the current root's registry — the registry of whatever --root points at. To manage the initramfs's own named roots, aim --root at the initramfs first.
peipkg root add <name> <path>
peipkg root remove <name> [--purge]
peipkg root list [--json] [--tree]
peipkg root show <reference> [--json]
root with no subcommand is a usage error — a subcommand is required (add, remove, list, show). An unknown subcommand is a usage error too.
root add #
peipkg root add <name> <path>
Register a named root in the current root's registry. <name> is a single root segment — it must match [a-z0-9][a-z0-9_-]* and must not contain a dot; you register one name at a time, not a dotted chain. <path> is stored relative to the current root. There are no flags.
$ peipkg root add initramfs boot/initramfs
root remove #
peipkg root remove <name> [--purge]
Unregister a named root. By default this removes only the registry entry — the files on disk are left in place, so the subtree survives and can be re-registered. --purge additionally deletes the named root's filesystem tree.
| Option | Effect |
|---|---|
--purge | Also delete the named root's filesystem tree, not just its registry entry. |
root list #
peipkg root list [--json] [--tree]
Print the current root's registry. Plain output lists the names registered directly under the current root.
| Option | Effect |
|---|---|
--json | Emit JSON — for each entry its name, its stored path, its resolved_path, its status, and its children. |
--tree | Recurse into each child's registry and print the whole nested arrangement. The recursion is cycle-guarded, so a registry that loops is reported rather than followed endlessly. |
root show #
peipkg root show <reference> [--json]
Resolve one reference and report on it. <reference> is either a dotted named-root reference or a path — the same path-or-name rule as --root. show reports the reference's resolved path, its status, and the number of packages installed in it.
The status is one of two words:
present— the resolved path exists on disk and is a real root.dangling— the reference resolves through the registries, but the path it names is not there. The binding exists; the tree it points at does not.
| Option | Effect |
|---|---|
--json | Emit the same report as JSON. |
present and dangling are the vocabulary the rest of this page uses for "the binding points at something real" versus "the binding is valid but its target is missing".
Automatic target selection with default_root #
A package's manifest can declare the root a top-level install of it should land in — its default_root. When you install such a package without saying where, peipkg reads that field and re-roots the install onto it for you.
$ peipkg install live-boot
If live-boot's manifest sets default_root: initramfs, this installs into the initramfs root even though no --root was given. The package's manifest records where it belongs, so you do not have to specify it.
Two rules keep this predictable:
- It applies only when no explicit
--rootwas given. Passing--rootwith any value, including a named one, states the target outright and suppressesdefault_rootre-rooting entirely. An explicit root always wins. - The packages in one command must agree. If the packages named in a single install declare two or more distinct default roots, peipkg cannot pick one and does not guess — the command is rejected as an error. Split it into one command per target.
default_root only chooses a target; it does not create or resolve anything the registry could not already reach. The chosen root is resolved through the registries like any other named reference.
Cascading upgrade #
By default peipkg upgrade reconciles the current root and every named root nested under it, recursively — the whole tree of roots, in one command. Run it against / on a dynamic-initramfs system and both / and boot/initramfs are brought current together.
Each root is upgraded as an independent transaction that continues on error: peipkg walks the tree, upgrades each root on its own, and prints a per-root summary. One root's failure does not abort the others — a broken upgrade in initramfs leaves /'s upgrade to complete and report normally, and vice versa. You get one summary per root and a clear picture of which succeeded.
Each of those per-root upgrades is an ordinary transaction with the full atomic guarantee of Transactions and recovery; the cascade runs several of them, it does not weaken any one.
| Option | Effect |
|---|---|
--no-recurse | Confine the upgrade to the current root only. The cascade into nested roots is disabled. |
Use --no-recurse when you deliberately want to move just one root — for example to upgrade the initramfs on its own without touching /, by pointing --root at it and disabling the cascade.
Cross-root dependencies #
A dependency can be routed into a different root than the package that declares it. A manifest writes this with IN:
Depends: foo IN initramfs
This says "I depend on foo, and foo belongs in the initramfs root" — the root name is resolved through the registries from the depending package's root. It is what lets a whole root be composed out of ordinary packages through the dependency graph: a single package installed into / can pull the pieces of the initramfs into initramfs as its dependencies, so the image is built by the same resolver and the same packages as everything else. An image builder starting from nothing can assemble an entire multi-root arrangement like this offline, from a declarative manifest, with the separate peipkg-compose binary — see Composing a root.
install is the only verb that crosses roots. When resolution produces a plan whose changes land in more than one root, that plan is committed as a two-phase commit across the participating roots, under a single generated cross-root transaction id that ties the per-root pieces together. Either the change lands in every participating root or in none — the multi-root plan is atomic as a whole, not merely per root.
A plan that reaches beyond the current root announces it before you approve:
- a
note: this also changes other roots: ...line naming the other roots the plan touches, and - a per-line
-> <root>tag on each plan entry that lands outside the current root, so you can see at a glance which change goes where.
You are never taken across a root boundary silently; a cross-root plan always shows its routing.
Cross-root undo and recovery #
Because a cross-root install commits as a unit, it reverses as a unit.
undo on a cross-root transaction reverses all of its participating roots together. There is no way to walk back only the / half of a change that also touched initramfs — the cross-root transaction id binds the pieces, and undo reverses the whole. See Keeping a system current for undo in general.
recover understands cross-root transactions too. It reconciles them across every reachable root, walking the registry outward from the current root to find each participant. A cross-root commit that was torn by an interruption is resolved the same way a single-root one is — with one addition that follows from the two-phase commit: a torn commit is rolled back if it was interrupted before its point of no return, or rolled forward to completion if it had already passed that point. Either way every participating root ends in the same, consistent state.
This is the single-root recovery model of Transactions and recovery extended across roots: the same commit-instant guarantee, now applied to a commit that spans several trees at once. Read that page for the underlying transaction model this builds on.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Failure — an unregistered name, a dangling or otherwise invalid reference, a resolution cycle, a per-root transaction that failed, or a cross-root commit failure. |
2 | Usage error — a missing or unknown root subcommand, a malformed option, or the wrong number of positional arguments. |
Composing a root
Peios / Using Peios / Package management
peipkg mutates a running system in place: every change is an atomic, reversible transaction against a live root. 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, and repository trust bootstraps on its first refresh.
It is meant for image builders — an outer image-assembly tool, or a person standing one up by hand. Because it only ever writes inside the output directory and never touches the host /, it is safe to run on a non-Peios host, and even on a host of a different architecture from the image being built.
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.
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. |
peipkg-compose build #
peipkg-compose build <manifest> --out <dir> [--locked | --update]
[--dangerously-bypass-path-restrictions]
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. |
--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.
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"
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. |
[[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. |
[[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 any version and the resolver picks the newest. |
repository | string | no | Pin the source repository. Must name a declared [[repository]]. |
root | string | no | Target a declared [[root]] — like peipkg install --root. 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.
[[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 .. |
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.
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.
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.
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 — those are the separatepeipkg-build,peipkg-repo, andpeipkg-managertools. Compose consumes ordinary peipkg packages and repositories unchanged, exactly as PSPU §5 defines them. - No side effects.
ldconfig,depmod, andman-dbare not run; no KACS security descriptors are applied; no audit events are emitted. A booted system runs those itself.
No environment variables are read, and there is no --version flag.
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. |
The registry
Peios / Using Peios / Concepts
The registry is the structured configuration store for a Peios system. It is a hierarchy of named keys, each holding values — typed pieces of data. Subsystems read their operational settings from it, services keep their definitions in it, and policy is delivered through it. Where a file holds an opaque stream of bytes, a registry value holds a small, named, typed datum that some part of the system knows how to act on.
It is also kernel-mediated. There is no file under /etc you edit and no daemon socket you talk to directly. Every registry operation — opening a key, reading a value, writing configuration, watching for change — goes through a dedicated set of system calls. Userspace never touches the store underneath; the kernel is always in the path, which is what lets the registry carry the same access control, the same identity model, and the same auditing as every other protected object in Peios.
This page sketches the shape, names the handful of ideas that make the registry its own thing, and points at the pages that cover each one.
The registry in one sentence #
The registry is a tree of keys and typed values, reached only through kernel system calls, where every key is an access-controlled object — and every value you read is the effective result of resolving a stack of layered writes.
Ignore that last clause for now. For almost everything in this section you can picture the registry as a plain hierarchical store: one key per path, one value per name, the value you wrote is the value you read. That simpler picture is correct as far as it goes, and it is the right way to learn the model. The layering underneath — what "effective" really means — is the deepest idea here, and it gets its own page once the rest is solid.
The shape #
Three entities make up the namespace.
| Entity | What it is |
|---|---|
| Hive | A top-level namespace — the first component of every path. Machine\ holds system-wide configuration; Users\<SID>\ holds per-user configuration. In practice a running system exposes just a few. |
| Key | A node in the tree. Keys are containers: they hold child keys (forming the hierarchy) and values (holding data). Every key is a secured object with its own security descriptor. Keys are roughly the registry's directories. |
| Value | A named, typed datum living inside a key — a REG_DWORD number, a REG_SZ string, and so on. A key can hold many values, each with a distinct name. Values are roughly the registry's files. |
flowchart TD
M["Machine\ (hive)"] --> S["System"]
S --> K["KMES (key)"]
K --> V1["BufferCapacity = 4194304 (REG_QWORD)"]
K --> V2["MaxEventSize = 65536 (REG_DWORD)"]
M --> R["Registry"]
U["Users\<SID>\ (hive)"] --> US["per-user keys..."]
Paths are written with backslashes — Machine\System\KMES — and compared case-insensitively while preserving the case you wrote. A handful of well-known roots anchor everything: Machine\ for the machine, Users\<SID>\ per principal, and CurrentUser\ as a convenience alias the kernel rewrites to the caller's own Users\<SID>\.
We will return to one subtree throughout this section as a running example: the KMES event subsystem reads its tuning parameters from values under Machine\System\KMES. It is small, real, and exercises every idea — types, meaning, security, and change notification.
What the registry is not #
The registry borrows a familiar shape, and the familiarity is a trap. Three wrong mental models attach themselves immediately; clearing them is most of understanding what the registry actually is.
It is not a filesystem. It looks like one — hierarchical paths, separators, containers and leaves — but it behaves differently in ways that matter. You reach it through dedicated registry system calls, not the file API. Values are typed data, not byte streams. Access is checked once, against the single key you open, with no traversal check on the keys above it — a process can read Machine\System\Services\Jellyfin without any access to Machine\System\Services. And security is attached per key; there is no such thing as a per-value permission.
It is not a dumping ground of opaque settings. Every key is a first-class secured object and every value is typed, access-controlled, and watchable. Nothing in the registry is unmanaged or hidden — there is no "miscellaneous junk" tier. It is a deliberate, governed surface, not a place things accumulate.
It is not self-describing. The registry stores a value's type tag but never interprets its bytes. It does not know that BufferCapacity must be a power of two, what its default is, or which subsystem reads it. That knowledge — the meaning of a value — lives entirely outside the registry: in the subsystem that owns the value, and, for a human looking it up, in regman, the registry's manual. regman is the man of the registry — give it a path and it tells you a value's type, default, valid range, when a change takes effect, and what the setting is for. The registry cannot describe itself, so its manual is shipped beside it. This separation of storage from meaning is the first idea worth slowing down for, and it has consequences — a write the registry accepts can still be refused by the subsystem that reads it — so it gets its own page.
It is not flat. Underneath the single value you read, the registry keeps a stack of writes, each tagged with a layer, and resolves the winner on every read. That is what makes configuration revert cleanly, role install and uninstall work, and domain Group Policy apply and lift without residue. It is powerful and it is the one idea worth deferring — the Layers page pulls the curtain back once the rest of the model is clear.
One registry, built from two parts #
When precision matters, "the registry" is really two cooperating components: a kernel subsystem that owns the data model, path resolution, access control, change notification, and layer resolution; and a userspace store that persists the data on disk. They talk over a private protocol, and the kernel is the only authority — the store never sees who is asking and never makes a security decision.
For the conceptual model you do not need that split, and these pages mostly treat the registry as one thing. The division becomes useful only when you care about how the registry is backed, swapped, or recovered — so it waits for LCS and sources, under Administration.
Where to start #
If you want the data model — hives, keys, the value types, case rules, and why a value is "typed but opaque" — read Keys, values, and types.
If you want the idea that the registry stores values it does not understand — what reject-or-keep means, and why the value the registry shows and the value a subsystem is actually using can legitimately differ — read Configuration, not storage.
If you are ready for the layered truth under the effective view — precedence, the base layer, how deleting a layer automatically reverts its changes, and how roles and Group Policy ride on it — read Layers.
If you want the security model — why every key carries a security descriptor, the registry-specific access rights, and the rule that security changes are not undone by layer removal — read Access control on keys.
If you want to know how a service reacts to a configuration change instead of polling for it, read Watching for changes.
For the two command-line tools, read regman — the registry's manual, which tells you what a key means — and reg — the scripting interface that reads and writes what a key is. You consult the first to decide what to set, and use the second to set it.
Keys, values, and types
Peios / Using Peios / Concepts
The registry's data model has exactly two kinds of thing in it: keys and values. A key is a node in the tree — a container that holds child keys and values. A value is a leaf — a named, typed piece of data living inside a key. There is no third kind of thing, and the nesting only ever happens through keys. That smallness is deliberate, and it is worth getting precise about before anything else, because every later idea is built on this shape.
Two levels, and only two #
Keys nest; values do not. A key can contain other keys (its subkeys) and it can contain values, but a value cannot contain anything — it is always a leaf. You cannot put a value "inside" another value, and there is no record type between a key and a value.
| Holds | Held by | Role | |
|---|---|---|---|
| Key | Subkeys and values | Its parent key | The container — the registry's directories |
| Value | Nothing (a leaf) | Exactly one key | The data — the registry's files |
This two-level rule is a hard constraint, not a convention. When you want structure, you make subkeys; when you want data, you make values. There is no other axis.
Keys #
A key is identified by its path — Machine\System\KMES names a key three levels down from the Machine\ hive root. Each key holds its children and its values, and each key is a first-class secured object with its own security descriptor; that is what makes "who can read or change this configuration" a per-key decision.
A few naming rules apply to each component of a path (each segment between separators):
- Any UTF-8 is allowed in a key name except backslash, forward slash, and the null byte. Backslash is the separator; forward slash is accepted on input and normalised to a backslash; null is never allowed.
- Components cannot be empty.
Machine\\System(a doubled separator) and a trailing separator are both invalid.
Every key also has one default value — the single value whose name is the empty string. It is the key's "main" value, the one you get when you read the key without naming a value. Most keys also carry additional named values alongside it.
Values #
A value is a (name, type, data) triple stored in a key. A key can hold many values, each with a distinct name, plus the one unnamed default value.
Value names follow almost the same rules as key names, with one difference: backslash and forward slash are allowed in a value name. Value names are not paths — they are not hierarchical — so a separator inside one has no special meaning. Only the null byte is forbidden, and the empty string is reserved for the default value.
The value types #
A value's type is a small tag stored alongside its data. The full set:
| Type | Holds |
|---|---|
REG_DWORD / REG_QWORD | A 32-bit / 64-bit integer. |
REG_DWORD_BIG_ENDIAN | A 32-bit integer in big-endian byte order. |
REG_SZ | A string. |
REG_EXPAND_SZ | A string containing references to be expanded (e.g. environment variables) by whatever reads it. |
REG_MULTI_SZ | An array of strings. |
REG_BINARY | Raw bytes with no further structure. |
REG_LINK | A symbolic-link target. The one type the registry acts on itself — see Registry links. |
REG_NONE | No type / no meaningful data. |
There are also three hardware-resource types carried over for format fidelity. Peios assigns them no meaning — they behave exactly like REG_BINARY and the registry never produces them itself. You will essentially never author one.
Typed, but opaque #
Here is the property the rest of the topic leans on. The registry stores a value's type tag and its raw bytes, and that is all it does with them. It does not check that the bytes match the type. It does not parse a REG_DWORD into a number. It does not know that Machine\System\KMES\BufferCapacity is supposed to be a power of two, what its default is, or which subsystem reads it. The single exception is REG_LINK on a link key, which the kernel follows during path resolution.
So "typed" here means tagged, not validated. The type travels with the value so that a reader knows how to interpret the bytes — but the interpreting, the validating, and the deciding-what-to-do are all somebody else's job.
Two consequences follow immediately, and both get their own treatment:
- The registry can hold a value for a subsystem that is not even running, or a setting nobody has read yet. Storage does not require a reader.
- Whether a value is valid is never the registry's verdict. That belongs to whatever reads it — which is the whole subject of Configuration, not storage.
Names, case, and paths #
Paths use the backslash as their canonical separator. A forward slash is accepted on input and normalised to a backslash, so Machine/System/KMES and Machine\System\KMES name the same key; the stored, canonical form always uses backslashes.
Comparison is case-insensitive but case-preserving. KMES and kmes resolve to the same key, but the registry keeps whatever case you wrote for display. The matching uses a fixed Unicode case-folding rule, so it does not depend on locale.
One sharp edge: there is no Unicode normalisation. Two different byte sequences that render as the same character (a precomposed é versus e plus a combining accent) are two different keys. Case is folded; representation is not.
Volatile keys #
A key can be created volatile, meaning it is stored only in memory and disappears on reboot or when its store unloads. It is the registry's home for runtime-only state that should never survive a restart. Volatility is fixed at creation, and there is one structural rule: the children of a volatile key must themselves be volatile (you cannot place a persistent key under a non-persistent one).
Watch the word, because it collides with a different idea. "Volatile" describes storage persistence — does this key survive a reboot. It says nothing about how quickly a configuration change takes effect — whether editing a setting applies live, on service restart, or only on reboot. That second property belongs to each individual setting and is recorded in regman as its applies field. A value can live in a perfectly persistent (non-volatile) key and still only take effect on reboot. Keep the two apart.
Where to go next #
If you want the idea that the registry stores values it does not understand — what regman documents, what "reject-or-keep" means, and why the value the registry shows can differ from the value a subsystem is using — read Configuration, not storage.
If you want the layered truth beneath the single value you read — precedence, the base layer, and automatic revert — read Layers.
If you want the security model — why every key carries a security descriptor and what the registry-specific access rights are — read Access control on keys.
Configuration, not storage
Peios / Using Peios / Concepts
A registry value is a type tag and a pile of bytes. The registry stores it, returns it on request, and protects it with a security descriptor — but it never asks what the value means. As Keys, values, and types put it, the registry is typed but opaque: it knows the tag, not the meaning. This page is about what follows from that, because it is the single most counterintuitive thing about the registry and the thing that most often trips people up.
Configuration and meaning, in one sentence #
The registry holds configuration; it does not understand it. Meaning lives in the subsystem that reads the value and in regman, the manual that documents it — never in the registry itself.
Every config system you have used before bundles storage and meaning together: the file holds the setting and the program that parses the file knows what the setting means, in one place. The registry splits them on purpose. Storage is the registry's job. Meaning lives in two other places.
Where meaning lives #
In the subsystem that owns the value. The code that reads Machine\System\KMES\BufferCapacity is the thing that knows it must be a power of two, knows the compiled-in default, and knows what to do when it changes. That knowledge is in KMES, not in the store. Ask the registry "is this a sensible buffer capacity?" and it has no answer — it was never told what the value is for.
In regman, for a human. regman is the registry's manual — the man of the registry. Give it a path and it tells you what a key or value actually does:
$ regman Machine\System\KMES BufferCapacity
Type REG_QWORD
Default 4194304 (4 MB)
Valid 65536–268435456 bytes (64 KB–256 MB), power of two
Applies live — ring-buffer swap
Per-CPU ring buffer capacity, in bytes. Must be a power of two; values
that are not are treated as invalid and ignored.
regman is shipped documentation that lives beside the registry, not inside it — because the registry cannot describe itself, the manual has to sit next to it. This is the backbone of configuring a Peios system: before you touch a knob, regman is how you find out what it is, what values are legal, and what changing it will cost you (the Applies line — live, on restart, or on reboot). regman reads its own shipped docs; it does not read the live registry, so it tells you what a setting is, not what it is currently set to.
Reject-or-keep #
Because the store never validates, validation happens where the value is read. This produces the rule that surprises people:
A write the registry accepts is just stored bytes. The subsystem that owns the value validates it on read — and a value it judges invalid is ignored, not applied. The subsystem keeps its last known-good value. It never clamps the bad value to the nearest legal one, and never silently corrects it.
flowchart LR
A["Admin writes a value"] --> B["Registry stores the bytes (always succeeds)"]
B --> C["Owning subsystem reads it"]
C --> D{"Valid?"}
D -->|yes| E["Apply the new value"]
D -->|no| F["Keep last known-good value + log the rejection"]
KMES is the worked example. Write a BufferCapacity that is not a power of two and the write succeeds — the registry has no opinion about powers of two. When KMES reads it, it rejects the value, keeps the capacity it was already using, and emits an event naming the key, the value it rejected, and the value it is still running on.
Written versus used #
That leaves two sources of truth that can legitimately disagree:
- The registry shows what was written. Read the key back and you see the value the admin set — including a rejected one, sitting there looking authoritative.
- The log shows what is actually in use. When a subsystem rejects a value and keeps its previous one, it says so in the event log. That record, not the registry read, is the truth about what the system is running on.
So "what is this subsystem actually configured to right now?" is not always answered by reading the registry. If a change does not seem to have taken effect, the registry will happily show you the value you wrote; the audit and event log is where you find out it was refused and why. This is why observability matters here: the registry is the intent, the log is the reality, and they are allowed to differ.
The registry does this to itself #
The cleanest demonstration is the registry subsystem configuring itself. It reads its own tuning parameters from Machine\System\Registry\ and applies exactly this discipline to them: a valid value is hot-swapped into effect; an invalid one (out of range, wrong type) is ignored, the previous known-good value is retained, and an audit event is emitted naming the key, the rejected value, and the value still in force. Values are never clamped. The subsystem that owns the entire registry treats its own configuration as "stored bytes I must validate before I trust" — see How the registry boots and configures itself.
There is no invalid registry #
Put plainly: a value is never "invalid" at the registry level, because the store has no standard to judge it against. Validity is a verdict, and the reader makes it. The same bytes could be valid to one subsystem and meaningless to another; the registry holds them either way.
So "is this configuration valid?" is not a question you ask the registry. You ask the subsystem that owns the value — or you read the log to see what verdict it already reached.
Why it is built this way #
Briefly, because it is worth knowing the trade rather than dwelling on it: keeping the store meaning-free keeps it small and uniform, lets it hold a value for a subsystem that is not running yet or a key nobody has read, and lets configuration be delivered from outside — a domain Group Policy, say — without the kernel needing a built-in schema for every subsystem on the machine. The price is that the store cannot tell you whether a value is sensible. That price is paid by regman (which documents what should be there) and the logs (which record what the system actually did).
Where to go next #
If you want to see how a subsystem notices a configuration change so it can re-validate and re-apply, read Watching for changes.
If you want the registry's own bootstrap and self-configuration story — the purest example of reject-or-keep — read How the registry boots and configures itself.
If you are ready for the layered model beneath the single value you read, read Layers.
Watching for changes
Peios / Using Peios / Concepts
Configuration changes while the system is running — an administrator edits a value, a role is installed, a policy is applied. A subsystem could poll the registry to notice, but Peios does not make it: the registry can tell a process when something it cares about changes. This is what lets services react to configuration instead of repeatedly re-reading it, and it is how the registry behaves as a live configuration backbone rather than a passive store.
Watching, in one sentence #
A process arms a persistent watch on a key — optionally covering its whole subtree — and is notified whenever the effective state there changes, so it can react to configuration instead of polling for it.
Persistent, not single-shot #
A watch is armed on an open key handle. Once armed, it stays armed until the handle is closed: events keep flowing, with no need to re-arm after each one. This matters because re-arming would open a race — a change slipping through in the gap between one notification and the next registration. There is no such gap here. The handle is also pollable: a process waits on it the same way it waits on any other file descriptor, and reads structured change records off it when they arrive.
A watch can cover just the one key, or the key and its descendants (a subtree watch), so a service can watch an entire configuration area with a single registration.
What a watch reports #
Events describe what changed, by kind:
| Event | Meaning |
|---|---|
| Value set | The effective value at a name changed or appeared. |
| Value deleted | The effective value at a name went away. |
| Subkey created / deleted | A child key became visible / became invisible. |
| SD changed | The watched key's security descriptor was modified. |
| Key deleted | The watched key itself is no longer reachable by path. |
| Overflow | Events were dropped — re-read to recover (below). |
Watchers see effective state, not layers #
Here is the important tie-back to Layers. A watcher is told about changes to the effective value — the winner of the contest — and nothing about the machinery underneath. Delete a layer and cause a value to revert, and the watcher sees a plain "value set" event for its new effective value. Apply a higher-precedence policy that overrides a local setting, and the watcher sees the value change. It never sees "a layer was added" or "a write lost a contest" — only that the answer it would get from a read is now different.
This is exactly right: the layering is an implementation of how the effective value is chosen, and a watcher only cares that it changed. The curtain stays down; the watcher watches the front of it.
Committed state only #
Watch events fire when a change commits, never mid-flight. A transaction that writes many values produces its events as one batch at commit time; a watcher never observes a half-applied transaction, and an aborted one produces no events at all. What a watcher sees is always a state the system actually reached.
Best-effort: be ready to re-read #
A watch is a change notification, not a guaranteed-complete journal. Events are queued for the watcher, and the queue is bounded. If a watcher falls behind — events arriving faster than it reads them — the queue overflows: the oldest events are dropped and a single overflow marker is delivered in their place. The contract on overflow is simple and firm: re-read the watched key (and subtree) to recover the current state, then carry on with subsequent events.
So a correct watcher is written to do two things — apply individual events when it is keeping up, and fall back to a full re-read when it is told it fell behind. It never assumes the event stream is a complete edit history; it treats it as "something changed, here is a hint, and if the hint says you missed some, go look." The same overflow-and-re-read recovery covers the bigger disruptions too — a large layer operation, or the backing store restarting — where computing an exact per-change list is not worthwhile.
The reaction loop #
Put watches together with reject-or-keep and you get the pattern that runs throughout Peios. A subsystem watches its own configuration subtree; when a value changes, the watch fires; the subsystem re-reads, re-validates, and either applies the new value or keeps its last known-good one and logs the rejection. KMES does this for its tuning; peinit does it for service definitions; the registry itself does it for its own parameters (see How the registry boots and configures itself). Configuration is something you react to, and watches are the mechanism.
One subtlety: a watch follows the object #
A watch is bound to the specific key object you opened, not to the path string. If layers later cause a different key to appear at the same path, your watch stays with the original object — it does not jump to the newcomer. If the original key is removed, you get a "key deleted" event; to watch whatever now lives at that path, you reopen the path and arm a fresh watch. This follows from keys having an identity of their own, distinct from the name that currently points at them.
Where to go next #
If you want the registry's own use of watches — how it picks up changes to its own configuration without restarting — read How the registry boots and configures itself.
If you want the validation half of the reaction loop — why a changed value might be read and then refused — read Configuration, not storage.
If you want what it takes to be allowed to watch a key, read Access control on keys.
Layers
Peios / Using Peios / Layers
So far the registry has been presented as if each value had a single stored entry: you write it, you read it back. That was the effective view — true as far as it goes, and the right way to learn everything up to here. This page replaces it with what is actually underneath, because the registry is a layered store, and the layering is both the reason the subsystem exists and the part most likely to be misunderstood.
The misunderstanding is worth naming up front. It is natural to picture layers as a clean stack — sheets of glass, one above another, the top sheet's value showing through. That picture is wrong in the case that matters most, and unlearning it is most of this page.
Layers, in one sentence #
A layer is a label stamped on a write and a handle for removing a group of writes together — not a position in a stack. The registry orders individual writes, not layers, and it chooses a winner separately for every single value.
Hold onto both halves. First: the thing that has a position is the write, not the layer. Second: resolution is per value — the contest is run again, from scratch, for every value name.
Every write is kept #
When you write a value, the registry does not overwrite anything in place. It records your write, tagged with the layer you wrote it in. If a different layer writes the same value, that write is stored alongside yours, with its own tag. So a single value name can hold several stored writes at once — one for each layer that has ever written it. (Within one layer there is only ever one: writing a value again in the same layer replaces that layer's entry. One layer, one opinion per value.)
A read, though, returns exactly one value — the effective value. Several stored writes, one answer: there is a contest, and the rest of this page is its rules.
How the winner is chosen #
Each stored write carries two ordering keys:
- precedence — a number it inherits from its layer. Higher wins.
- recency — its place in a single, global, monotonic write-order counter. Later wins.
For one value, the registry gathers every active write to it and picks the winner by those keys in order: highest precedence first; among writes that tie on precedence, the most recent. That is the whole rule.
flowchart TD
W1["write · layer base · seq 300"] --> R{"tie on precedence — most recent wins"}
W2["write · layer role-jellyfin · seq 50"] --> R
R --> E["effective value = base (seq 300)"]
The subtlety is entirely in how those two keys play out in practice — because in practice, one of them almost never varies.
Precedence is the exception; recency is the rule #
Here is the fact that breaks the stack picture: almost every layer has the same precedence. The base layer is precedence 0. Role layers are precedence 0. Only a few things — chiefly domain policy — ever sit higher. So for the overwhelming majority of contests, precedence is a tie, and the winner is decided purely by recency, value by value.
That has a consequence the stack picture cannot express. Two layers at the same precedence have no fixed pecking order between them. For one value, layer A might win because it wrote that value most recently; for the value right beside it, layer B wins because it wrote that one last. Same two layers, same precedence, different winners — at the same instant.
A worked example. An administrator has made some manual edits (which land in the base layer) and has installed a role (role-jellyfin), both at precedence 0:
| Value | base wrote (seq) | role-jellyfin wrote (seq) | Effective value | Why |
|---|---|---|---|---|
MaxEventSize | 300 | 50 | base | tie on precedence → most recent write wins |
BufferCapacity | — | 250 | role-jellyfin | base never wrote it |
MaxNestingDepth | 100 | 280 | role-jellyfin | role wrote it more recently |
There is no "top layer" here to point at. base owns one value, role-jellyfin owns two, and which is which is decided one value at a time by who wrote last. This is what "interwoven, not stacked" means, and it is the normal case, not an edge case.
So the stack-of-glass-sheets picture is only ever right when precedences genuinely differ — which is the minority. Drop it as your default image.
A picture that fits #
If you want one image to keep, use a shared document where, for each cell, the last edit wins — many editors, no locking, the most recent edit showing. That is the same-precedence case exactly: per-cell (per-value) last-write-wins, with no editor inherently above another. Precedence, when it appears, is an administrator who can lock a cell: once locked, that cell holds the locked value no matter who edits afterwards. Every metaphor leaks, but this one leaks in the right places — it foregrounds that resolution is per-cell and recency-driven, and casts precedence as the rare override it actually is.
When precedence really does differ #
When two writes have different precedence, recency stops mattering between them: higher precedence always wins, even over a more recent lower-precedence write. A value set by a precedence-1 layer long ago still beats one written to a precedence-0 layer a moment ago.
This is precisely what makes precedence worth having. Recency is fine for cooperating local configuration, but it cannot express "this setting must win even if someone writes it again later" — and that is exactly what policy needs. A domain Group Policy is delivered as a higher-precedence layer for this reason: a local administrator cannot defeat it by re-writing the value, because their write lands at precedence 0 and loses to the higher tier regardless of how recent it is. Precedence is the mechanism behind "you cannot override this locally". (Creating a layer that outranks others is itself a privileged action, so the tiering cannot be forged from below — more on that in Access control.)
"Most recent" is write order, not the clock #
One precision worth stating, because it is what makes the model trustworthy: recency is a monotonic write-order counter, not a wall-clock timestamp. Every write is handed the next number from a single counter that only ever increases. The registry does record a wall-clock "last write time" on each key, but only as human-facing metadata — it is never used to resolve a contest. So "most recent" means "later in the actual order of writes", which cannot be moved backwards or spoofed by a clock change.
It is not only values #
Everything above is framed around values, but the same contest decides whether a key exists at a path. Each layer can make its own claim about a name — "a key lives here" — and the winner is chosen by the same precedence-then-recency rule. A layer can even claim that nothing lives there, masking a key another layer provides. That is how a layer adds, replaces, or hides a key, and it resolves exactly as a value does. The markers that express absence — tombstones for values, hidden entries for keys — are the subject of the next page.
Where to go next #
If you want the payoff — why all of this exists — read What layers are for: the base layer, tombstones, the automatic-revert property, and how roles and Group Policy are built on it.
If you want the one thing layers do not revert — security — read Access control on keys. A security change made while a layer existed is not undone when the layer is removed, and the reason is worth understanding.
If you want to know how a watcher sees a layer change — it observes effective-state changes, with the layering made invisible — read Watching for changes.
What layers are for
Peios / Using Peios / Layers
Layers explained how the registry resolves competing writes. This page is the reason it bothers. The layered model buys one thing above all: configuration you can add and remove as a unit, with revert that is automatic and leaves nothing behind.
The payoff, in one sentence #
Because every write is tagged with a layer and nothing is overwritten in place, deleting a layer makes its writes disappear and the next-best write for each value resurface on its own — so installing a bundle of configuration and removing it again are just creating and deleting a layer.
Everything below is a consequence of that.
The base layer #
Most of the time you are not thinking about layers at all, and the base layer is why. It is the default layer — precedence 0 — and it is where writes go when you do not ask for anything else. Manual administrative edits land here; so do the system's own defaults. It always exists, and it cannot be deleted or disabled; it is the floor the rest of the model stands on.
When earlier pages showed you "write a value, read it back", that was the base layer doing its job. You can work with the registry for a long time and only ever touch base.
Saying "no value", not just "another value" #
A plain write competes to be the value. But configuration sometimes needs to say something a plain write cannot: this value must not be set at all. Overriding MaxEventSize with a different number is easy; asserting that MaxEventSize should be absent is a different statement.
A layer makes it with a tombstone — a write whose meaning is "no value here". It enters the same per-value contest as any other write (Layers); if it wins, a read of that value returns "not found" rather than falling through to some other layer's write. Like any write, it belongs to a layer — so when that layer is removed, the tombstone goes with it and whatever it was suppressing comes back.
A blanket tombstone is the same idea applied to a whole key at once: a marker that enters the contest as a "no value" candidate for every value name on the key. A layer can set a blanket tombstone and then write the specific values it does want — the effect is "clear everything here, then set these". It is how a layer declares the complete contents of a key instead of merging into whatever was already there.
The key-level equivalent is hiding: a layer can claim that no key exists at a path, masking one that an older or lower-precedence write provides. Remove the layer and the key reappears.
In every case the marker is just another write — owned by a layer, removed with the layer.
Automatic revert #
This is the property the whole design exists to deliver. Because layers are removable and nothing is ever destroyed in place, deleting a layer cleanly undoes everything it did — with no undo script, no bookkeeping, no leftover state:
- Its writes vanish from every contest they were in.
- Its tombstones and hidden-key markers lift.
- For each affected value or key, the registry simply re-runs the contest without the deleted layer's writes, and the next-best survivor becomes effective.
There is no separate "revert" operation. Revert is what naturally happens when a layer's writes stop existing, because the effective value was always just the winner of a live contest.
flowchart LR
A["role-jellyfin present: MaxNestingDepth = role's value"] -->|delete the layer| B["role's write gone: MaxNestingDepth reverts to the base value underneath"]
There is, in particular, no tattooing — the failure mode where removing configuration leaves its changes burned in. In a system that overwrites in place, uninstalling something means trusting that its installer recorded the previous values and restores them correctly. Here there is nothing to restore: the previous value never left.
Roles #
A role is a bundle of configuration deployed as a layer. Installing a role creates a layer and writes the role's keys and values into it (atomically, as one transaction, so the role never appears half-installed). Uninstalling a role deletes the layer — and by automatic revert, every value it set and every key it added simply falls away, and whatever the system looked like before resurfaces on its own. Clean uninstall is not something a role's authors have to implement carefully; it is a property of the layer.
Group Policy #
Domain Group Policy is configuration delivered from outside the machine, and it rides on layers too — but at a higher precedence than local layers. That choice is deliberate, and Layers explained why: a higher-precedence write wins regardless of recency, so a domain setting beats local configuration even if a local administrator writes the value again afterwards. Applying a policy adds the layer; lifting it deletes the layer, and the local configuration it had been overriding resurfaces automatically — the same revert, one tier up. (High-precedence layers are privileged to create, so policy cannot be forged by an unprivileged process; see Access control.)
What layers are not #
- Not a transaction. Layers decide which write wins and let you remove a group of writes together; atomicity — making several writes commit all-or-nothing — is a separate mechanism. A role install uses both: a transaction to apply the writes atomically, a layer to make them removable.
- Not access control. A layer does not decide who may read or change a value; the security descriptor on each key does. And the two do not mix: a security change made while a layer existed is not reverted when the layer is deleted. Security is operational state, not configuration overlay — Access control on keys is where that distinction lives.
- Not a browsable history. Layers are not a version-control timeline you can scroll through. You see the effective view — the current winners — not a log of every write that ever competed.
Where to go next #
If you want what "deleting a key" really does once names are layered — and why there is no recursive delete — read Deleting keys and values.
If you want the security model and its sharp interaction with layers — why deleting a layer reverts its values but not a security change made under it — read Access control on keys.
If you want to see how a watcher experiences a layer being added or removed — it sees the effective values change, with the layer machinery invisible — read Watching for changes.
For the sandboxing case — per-thread private layers that only one caller sees — read Private hives and layers.
Deleting keys and values
Peios / Using Peios / Layers
Deletion is one more thing the layered model quietly reshapes. "Delete this key" sounds absolute, but in a store where a name is the winner of a per-layer contest, removing a key really means withdrawing one layer's claim to that name. Several behaviours follow that are worth knowing before you delete anything.
Deletion, in one sentence #
Deleting a key withdraws one layer's claim to a name; the key disappears only if no layer still claims it — and even then, anything holding it open keeps working until it lets go.
Deleting withdraws a claim #
When you delete a key, you remove its name in a particular layer — the base layer unless you say otherwise. Nothing about the key is special-cased; its claim in that layer is simply withdrawn, and it drops out of the contest for that name.
If another layer still names the key, it stays visible through that layer. So deleting a key that a role also provides removes only your claim — the role's key remains until the role does. To remove a key everywhere, every layer's claim has to go. For anything delivered by a role or a policy, that means removing the layer (which reverts cleanly) rather than deleting the key — deleting it in the base layer would not touch the role's claim anyway.
(The hive roots themselves — Machine\, Users\<SID>\ — cannot be deleted or hidden. They are the anchors the namespace hangs from.)
There is no recursive delete #
You cannot delete a key that still has visible child keys; the deletion is refused. There is no "delete this whole subtree" primitive. Removing a populated subtree is a deliberate walk from the leaves upward, performed by whatever tool you are using — not a single sweep in the kernel.
This is a safety property, not a limitation to work around. A mistaken delete cannot take a populated subtree down with it; you have to mean it, key by key.
Deleting out from under an open handle #
A key can be deleted while a process still has it open, and the registry handles that the same way Linux handles deleting an open file (the unlink model). The open handle keeps working — reads, writes, and watches all continue against the now-unnamed key — and the key is only truly discarded once the last handle closes. Meanwhile, new attempts to open it by path fail at once: the name is gone, even though the object lingers for whoever still holds it.
So "deleted" means "no longer reachable by name", not "destroyed this instant". A service that had the key open does not break mid-operation; it holds the last reference until it closes, and only then does the key go away.
Deleting values #
A value works the same way one level down. Deleting a value withdraws a layer's write for that value name. If a lower-precedence or older layer also wrote that value, its write resurfaces as the new effective value — the ordinary revert. Remove a value's only write and the value simply becomes absent.
Permission #
Deleting (or hiding) a key requires delete permission on it — see Access control on keys. As everywhere in the registry, the check is against the key you are deleting, decided by its security descriptor, with no check on the keys above it.
Where to go next #
For the contest that "withdrawing a claim" feeds back into, read Layers.
For removing configuration in bulk — deleting a layer, which is what you do instead of deleting a role's or policy's keys — read What layers are for.
Access control on keys
Peios / Using Peios / Security
Every registry key is a secured object. It carries a security descriptor — owner, DACL, and optionally a SACL — exactly like a file, a process, or a token. And the registry does not invent its own access logic: opening a key runs the same AccessCheck pipeline that governs every other protected object in Peios, against the same tokens and the same SD format. If you understand access control for files, you already understand most of it for the registry.
Access control on keys, in one sentence #
Every key carries a security descriptor, and access to it is decided by the same AccessCheck that governs everything else — evaluated once when you open the key, then cached on the handle for the life of that handle.
Security is per key, not per value #
A security descriptor lives on the key. Values do not have their own — they inherit their key's access control. "Who can read this value, who can change it" is answered by the SD on the key that contains it, and there is no finer-grained permission than that. If two values need different access rules, they belong in different keys.
The handle model #
Access is checked at open, not on every operation. When you open a key for some set of rights, AccessCheck runs once; if it grants them, you get a key handle (a file descriptor) with a granted access mask baked in. Every later operation through that handle is a cheap bitmask check against the cached mask — is this operation's required right in what I was granted? — not a fresh AccessCheck.
The consequence is the same check-at-open rule that files and central access policies follow: changing a key's SD affects future opens, not handles that are already open. An administrator who tightens a key's DACL does not retract access from a service that already has the key open — the recourse is to make the service reopen (typically by restarting it). The handle is a snapshot of the decision made at open time.
The registry-specific rights #
A key's DACL is written in terms of rights specific to keys:
| Right | Gates |
|---|---|
KEY_QUERY_VALUE | Reading values. |
KEY_SET_VALUE | Writing and deleting values. |
KEY_CREATE_SUB_KEY | Creating child keys. |
KEY_ENUMERATE_SUB_KEYS | Listing child keys. |
KEY_NOTIFY | Arming a watch. |
KEY_CREATE_LINK | Creating a link key (privileged). |
DELETE | Deleting the key, or hiding it in a layer. |
READ_CONTROL | Reading the SD and key metadata. |
WRITE_DAC / WRITE_OWNER | Changing the DACL / the owner. |
ACCESS_SYSTEM_SECURITY | Reading or changing the SACL (audit policy). |
The usual convenience bundles apply — KEY_READ (query, enumerate, notify, read the SD), KEY_WRITE (set values, create subkeys), and KEY_ALL_ACCESS.
Ordinary access — reading a value, writing one, creating or listing subkeys — is decided entirely by the key's SD through AccessCheck. No privilege grants ordinary registry access. A few privileges appear only where a write means more than storage: establishing a layer that outranks others (policy — see Layers), creating a link, or bulk backup and restore. Those are special operations, covered where they arise; everyday access is the SD's job alone.
No traversal check #
One surprise sets the registry apart from a filesystem: only the SD on the key you open is checked. The keys above it on the path are not. A process can open Machine\System\Services\Jellyfin with no access whatsoever to Machine\System\Services or Machine\System. There is no "execute/traverse" right that you must hold on every ancestor the way a filesystem demands. Access is decided at the destination, full stop.
This matters when you reason about exposure: locking down a parent key does not lock down what is beneath it. If a subtree must be protected, the protection has to be on the keys that hold the data, not merely on a key somewhere above them.
Where a key's SD comes from #
A key's SD is computed once, at creation, by inheriting from its parent — the same eager, static inheritance files use. It is then a complete value stored on the key; the parent is not consulted again at access time. Changing a parent's SD does not ripple down to children that already exist — re-applying inheritance to an existing subtree is a deliberate administrative walk, not something that happens on its own.
The chain has to start somewhere, and it starts at the hive roots, whose SDs are the seeds for everything below:
| Hive root | Default access |
|---|---|
Machine\ | SYSTEM and Administrators: full control. Authenticated Users: read. (All inheritable.) |
Users\<SID>\ | That user, SYSTEM, and Administrators: full control. |
Subsystems that need something tighter than "Authenticated Users can read" set an explicit SD on their own subtree root at creation, overriding the inherited default for everything beneath it.
Watching requires permission to read #
Arming a watch needs KEY_NOTIFY, so a process cannot monitor a key it could not otherwise observe. One deliberate asymmetry: a subtree watcher learns that a descendant key was created or deleted — structural facts — without holding any access to that descendant. It learns that something appeared; it must still open it (and pass AccessCheck) to read what is inside. Structure visibility is intentionally weaker than content visibility.
The sharp edge: security is not layered #
This is where the registry's two big ideas meet, and the result surprises people. Layers revert cleanly — delete a layer and its values fall away. A security change does not.
A key's SD is a direct property of the key object. It is not tagged with a layer and it is not a write in the per-value contest. So when you change a key's DACL — tightening access, say — you are mutating the key itself, permanently. If a role layer existed at the time and is later uninstalled, the values it set revert, but the security change you made stays exactly where it is.
The reasoning is deliberate: security is operational state, not configuration overlay. Imagine the alternative — an administrator locks down a sensitive key while some unrelated role happens to be installed, and then removing that role silently reopens the key. Configuration is the kind of thing you want to revert in bundles; an access decision is not. So the registry keeps them on different tracks: values and key existence are layered and revertible; ownership and permissions are mutations on the object that outlive any layer.
The short version: layers revert what the system is configured to do; they never revert who is allowed to do it.
Where to go next #
If you want to see how a process reacts to a change instead of polling for it — and how watch events report effective-state changes with the layering invisible — read Watching for changes.
If you want the kernel/store split underneath all of this — who actually holds the SDs, and the trust boundary that follows — read LCS and sources.
For the broader access model these rights plug into — the AccessCheck pipeline itself — read Access decisions.
Default security descriptors
Peios / Using Peios / Security
Some software stamps security descriptors onto objects it creates at runtime — a freshly mounted filesystem root, a state file, a spool directory. Each of those descriptors is a policy decision: who can enumerate this directory, who can read this file, what everything created beneath this root will inherit. Decisions like that are configuration, and in Peios configuration has one home. The SdDefaults convention is the standard place a component keeps these descriptors, so that an operator can inspect them, change them, and trust that every component publishes them the same way.
Default security descriptors, in one sentence #
A component keeps each security descriptor it stamps at runtime as a named SDDL value under Machine\Software\<Software>\SdDefaults\, with a compiled-in copy as the fallback — a value that is present and valid overrides the compiled default; a value that is invalid is ignored, loudly.
The convention #
Machine\Software\<Software>\SdDefaults\<SD Name>
Machine\Software\<Software>is the component's own configuration key — the same key the rest of its settings live under.SdDefaultsis the literal subkey name.<SD Name>names the object the descriptor protects:SpoolDirectory,StateFile,Run. One value per descriptor, stored as a string containing SDDL.
A fictional spooler that creates its spool directory at startup would publish:
Machine\Software\ExampleSpooler\SdDefaults\SpoolDirectory
REG_SZ "O:SYG:SYD:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)"
The defaults are per component, deliberately. A single shared tree of default descriptors would need every SD name to be unique across every application ever written — a namespacing promise nobody can keep. Scoping the names under the component that reads them dissolves the problem, and it keeps discovery unsurprising: a component's descriptors live where the rest of its configuration lives.
Compiled default, registry override #
The registry value is an override, never the only copy. Every component that follows the convention carries a compiled-in default for each named descriptor, and resolves the one to use at the point where it stamps it:
State of SdDefaults\<SD Name> | Descriptor in force |
|---|---|
| Absent | The compiled-in default. This is the normal state — the key need not exist at all. |
| Present, valid SDDL | The registry value. |
| Present, invalid | The compiled-in default. The stored value is ignored, and an event records the key, the rejected value, and the descriptor actually in force. |
This is the registry's general reject-or-keep rule applied to descriptors, and here it is doing its most important work. A security descriptor that fails to parse is never repaired, never approximated, and never replaced with something broader: the descriptor in force is always one that was compiled in and reviewed. A typo in an SdDefaults value costs you your customisation, not your system.
When a change takes effect #
A default descriptor is read when the component stamps it — typically when the object is created. Two consequences follow:
- Changing a value affects objects stamped after the change. It does not rewrite objects that already exist.
- Where the stamped object is a directory whose descriptor carries inheritable ACEs, everything created beneath it derives its descriptor at creation time (inheritance is computed once, when the child is born). Changing the default later does not re-derive existing children.
So the honest answer to "when does my change apply?" varies by descriptor — next boot, next mount, next time the object is recreated — and the component's regman documentation is where that answer lives. Every <SD Name> a component publishes has a regman entry, and its Applies line states exactly this.
The values are access policy — protect them #
Whoever can write a component's SdDefaults values decides what access the component will grant on the objects it creates. These values are not settings about security; they are the security. A component's SdDefaults key therefore carries a tight security descriptor of its own — writable by Administrators and SYSTEM, no wider — which the registry enforces like any other key. The arrangement is self-hosting: the store that holds the descriptors is protected by the same mechanism the descriptors configure.
What the convention does not cover #
Bootstrap seeding. The very first descriptors of a boot — the seed stamped onto a fresh root before any registry source is attached — are compiled into the tools that write them, and are not customisable through the registry. There is no registry to read at that point in boot; that is not a gap in the convention but the reason it has a floor.
Kernel fallbacks. The descriptors the kernel synthesises when a mount policy has no template, and the default DACL applied when a created object has no parent to inherit from, are fixed. They are the floor under a missing policy, not configuration.
Files installed by packages. A package payload entry gets its descriptor by inheritance from its destination directory, or from a declaration in the package manifest — see PSPU §5.20. SdDefaults governs what software stamps at runtime, not what the package manager installs.
For component authors #
If your component stamps descriptors at runtime, follow the convention:
- Ship a compiled default for every named descriptor. The registry value is an override; your component must work, with reviewed policy, on a system where the
SdDefaultskey has never been created. - Name each value for the object it protects, not for its content —
SpoolDirectory, notSystemOnlyOici. - Resolve with reject-or-keep. An unparseable value is ignored in favour of the compiled default, and the rejection is recorded in an event naming the key, the rejected value, and the descriptor in force. Never substitute anything broader than the compiled default.
- Document every name in
regman, including anAppliesline that says when a change is picked up.
Where to go next #
For the doctrine behind reject-or-keep — why the registry stores values it does not validate, and why readers keep their last known-good — read Configuration, not storage.
For what an SDDL string actually says — owner, DACL, ACEs, and the inheritance flags that make one descriptor govern a whole tree — start at Security descriptors and Inheritance.
For protecting the SdDefaults key itself, read Access control on keys.
How the registry boots and configures itself
Peios / Using Peios / Administration
The registry holds the configuration for everything on the system. Which raises an awkward question: what configures the registry? The honest answer is that it mostly configures itself — and doing that means breaking a circular dependency, because the registry's own settings live in the registry, and the store those settings live in is itself a service that has to start up. This page is how that knot is untied, and it doubles as the purest example of the reject-or-keep discipline.
Bootstrap, in one sentence #
The registry subsystem is fully operational on compiled-in defaults from the instant the kernel loads, and hot-swaps to registry-backed configuration once its store comes up — it never enters a "waiting for configuration" state.
The circular dependencies #
Three loops have to be broken at boot:
- The registry reads its own operational parameters (timeouts, size limits, and so on) from a key under the
Machine\hive — but that hive is held by a store that has not started yet. - Other early services want to read their configuration from the registry before that same store is up.
- On a brand-new install there is no data at all: the store's database is empty.
A configuration store that refused to function until it was configured would deadlock on the first of these. The registry is designed so that never happens.
Compiled-in defaults #
Every operational parameter the registry uses has a compiled-in default that produces correct behaviour. From the moment the kernel module loads, the registry runs on those defaults — it is immediately operational and never blocks waiting for configuration. The base layer likewise exists unconditionally, hardcoded, needing no persisted state. So before any store has registered, the machinery is already alive; it simply has no hives to serve yet.
Hot-swap, not restart #
When the store registers and the configuration keys become readable, the registry reads, validates, and swaps its parameters in place — no restart, no re-initialisation. Operations already in flight finish on the values they started with; new operations pick up the new values. The transition from "compiled-in defaults" to "registry-backed configuration" is seamless and is the only configuration transition there is.
flowchart LR
A["Kernel loads — registry live on compiled-in defaults"] --> B["Store registers its hives"]
B --> C["Read Machine\System\Registry\* parameters"]
C --> D["Validate and hot-swap into effect"]
It applies reject-or-keep to itself #
This is the cleanest demonstration of the idea from Configuration, not storage: the registry treats its own configuration as untrusted bytes it must validate. A parameter value that is valid is hot-swapped into effect. One that is invalid — out of range, wrong type — is ignored: the registry keeps the value it was already using and emits an audit event naming the key, the rejected value, and the value still in force. It never clamps the bad value to something legal. The subsystem that owns the entire registry does not trust even its own settings until it has checked them — and when stored and in-force disagree, the audit log is the truth.
First boot, with no data #
A fresh install has an empty store, and the sequence is built to cope:
- The store —
registryd, the base registry source peinit starts at early boot (see LCS and sources) — starts, finds its database empty, and creates the hive root keys with their default security descriptors — but no configuration beneath them. - The registry looks for its parameter keys, finds nothing, and keeps its compiled-in defaults. It is fully operational regardless.
- The init system (not the registry's concern) restores a seed — a backup of the system's initial configuration — that populates
Machine\with the system's real configuration. - That write trips the registry's watch on its own configuration area; it re-reads, validates, and hot-swaps to the seeded values. Normal operation continues.
At no point is there a stall. Empty store, missing keys, seed arriving later — each is handled by "use the defaults until something better shows up", driven by the same watch mechanism every other reactive consumer uses.
The self-watch #
The registry notices changes to its own configuration the same way any service notices changes — by watching the subtree — except the watcher is internal to the kernel rather than a userspace handle. It is the reaction loop turned on the registry itself: a change to a parameter key fires the watch, the registry re-reads and re-validates, and applies or rejects. There is no polling and no special-case configuration path; self-configuration is just the registry being one more consumer of the registry.
Where to go next #
If you want the store itself — what it is, how the kernel and the userspace store divide the work, and the trust boundary between them — read LCS and sources.
If you want the validation discipline this page leans on, read Configuration, not storage.
If you want the change-notification mechanism behind the self-watch, read Watching for changes.
Backup and restore
Peios / Using Peios / Administration
Beyond reading and writing individual values, the registry can move whole subtrees at once: export a key and everything beneath it to a stream, and restore such a stream back into the tree. This is the bulk-data path, and it is the mechanism behind things you have already met — first-boot seeding — as well as the disaster-recovery and migration any real deployment needs.
Backup and restore, in one sentence #
Backup streams a point-in-time copy of a subtree out; restore replaces a subtree wholesale from such a stream — both are privileged, whole-subtree operations, not value-by-value edits.
Backup is a point-in-time snapshot #
A backup captures a key and its entire subtree as it stands at one instant — a consistent snapshot, unaffected by writes happening concurrently. The result is a self-contained stream you can send to a file, a pipe, or across a network.
It is full-fidelity with respect to layers: a backup records every layer's writes, not merely the effective values. Restore it and the layered structure comes back intact — the base values, the role layers, the policy overlays, all of them, resolving the way they did before. A backup is not a flattened picture of "what the values currently are"; it is the whole stack.
The stream is also self-verifying: it carries an integrity check, so a truncated or corrupted backup is caught when you try to restore it, rather than restored as garbage.
Restore is replace, not merge #
This is the rule to internalise. Restoring into a key replaces that key's contents and entire subtree: the existing descendants are removed and the backup's contents take their place. It is not a merge and it does not add to what is there — whatever was under the target key before is gone, supplanted by the stream.
The target key itself survives as the anchor — it keeps its identity and its place in the tree — and everything beneath it is rebuilt from the backup. The whole operation is atomic: it either completes and swaps the new subtree in, or fails and leaves the original untouched. There is no half-restored state to clean up.
Both bypass per-key permissions — by design #
Backup and restore do not consult the security descriptor on each key the way an ordinary open does. They are gated by privilege instead — a backup privilege to read the whole subtree, a restore privilege to write it. This is the same model as file backup: a backup operator reads files they were never granted access to, because reading-for-backup is the privilege, not per-file permission.
(Both operations are audited every time they run, regardless of a key's own audit settings — see the auditing topic.)
What it is for #
- First-boot seeding. A fresh machine's registry is empty; its initial configuration is delivered by restoring a seed. Same mechanism, described in bootstrap.
- Disaster recovery. Snapshot a subtree — or a whole hive — and restore it after a failure or a bad change.
- Migration. Move configuration between machines by backing up on one and restoring on another; the stream is portable.
Where to go next #
For where restore comes from at first boot, read How the registry boots and configures itself.
For who actually performs these operations — the kernel coordinating the store — read LCS and sources.
For the privilege model they lean on, read Access control on keys.
LCS and sources
Peios / Using Peios / Administration
Every page until now has treated the registry as a single thing, and that was the right altitude to learn the model. This page pulls it apart, because at the implementation level "the registry" is two cooperating components — and the division between them is clean, deliberate, and worth knowing once the concepts are solid.
The split, in one sentence #
The registry is a kernel subsystem (LCS) that is the sole authority for the data model, access control, layer resolution, and change notification — plus one or more userspace sources that do nothing but persist and return data; the base source, registryd, is loregd by default and backs the Machine and Users hives.
Who does what #
The dividing line is sharp, and it is the principle the whole design rests on: the kernel decides meaning; sources only store.
| The kernel subsystem (LCS) owns | A source owns |
|---|---|
| The data model — keys, values, types | Persisting entries to disk and returning them |
| Path resolution and routing | Nothing about who is asking |
| Access control — running AccessCheck | No security decisions at all |
| Layer resolution — choosing the effective value | No idea which write is effective |
| Watch dispatch and transaction coordination | Executing storage operations it is told to |
A source never sees a caller's identity, never evaluates a security descriptor, and never resolves a contest between layers. It stores writes tagged with layer names and hands all of them back when asked; the kernel does the deciding. That is why earlier pages could say "the registry checks", "the registry resolves" — it is always the kernel doing it, never the store.
flowchart LR
P["Any process"] -->|registry system calls| L["LCS (kernel): access control, layer resolution, watches"]
L -->|private protocol| S["Source — e.g. loregd (userspace): storage only"]
S --> DB["On-disk database"]
Userspace never talks to a source directly. Every registry operation goes through the kernel; the source talks only to the kernel, over a private protocol on a dedicated device. This is what lets the registry carry the same identity model, access control, and auditing as everything else — the kernel is unavoidably in the path.
Why split it this way #
Storage is a separable concern. Putting it in userspace means the backing store can be replaced, or different stores can back different hives, without changing the kernel — and the store can be developed, tested, and hardened as an ordinary (if privileged) service. The kernel keeps the parts that must be trusted and uniform — the data model and the security model — and delegates the part that is "just a database".
Hives are how the work is parcelled out: each hive is backed by exactly one source, and a source may back several hives. In practice the base source registers Machine and Users; other sources could register additional hives.
The base source: registryd #
The standard hives have to exist before anything can read configuration, so one source is always started at boot. registryd is that base source — the registry store peinit starts in early boot, and the first thing to register hives with LCS so the rest of startup has configuration to read. Its interface is deliberately minimal: one HiveName=path argument per hive, naming the hive and the on-disk database that backs it.
registryd Machine=/var/state/registry/machine.regdb Users=/var/state/registry/users.regdb
That is the whole of its configuration — registryd is the configuration store, so it takes what it needs from its arguments rather than from a config file of its own.
registryd is a role, not a fixed program. loregd — the Local Registry Daemon, which backs each hive with a SQLite database — is the default implementation, and the one running on a stock system. But the name is a swappable slot: another source can ship a registryd claim, and installing it puts that source in loregd's place with no change to anything that depends on the registry. Whatever holds the role starts the same way and answers to the same name. (Writing an alternative source is a developer task — the Peios SDK covers the protocol one speaks to the kernel.)
The trust boundary #
It is worth being blunt about the security boundary, the same way the rest of these docs are. A source is part of the trusted computing base. The kernel runs AccessCheck, but it runs it against the security descriptor the source hands back — so a compromised source can return a permissive SD for a key and cause access that should have been denied, or fabricate the precedence of a layer and tilt every contest in the system. The kernel validates that a source's responses are structurally well-formed and rejects malformed data, but it cannot detect a well-formed lie.
This is not a gap so much as a fact about where the boundary is: trusting the store to tell the truth about what it stores is inherent in having a userspace store at all. The mitigations are operational — a source runs with tightly scoped privileges, is protected by the security descriptors on its own service definition, and is managed as a critical service (with process-integrity protection where available). The honest summary is: the store is small, privileged, and trusted, and protecting it is part of protecting the system.
Two operations the kernel coordinates #
Two registry capabilities are the kernel orchestrating a source rather than features of the data model: atomic transactions, and backup and restore. In both, the source does the storage work — committing a batch atomically, or reading out and replacing a subtree — while the kernel coordinates it and enforces the rules around it. Each has its own page; the point here is only that the same division of labour holds: the kernel decides, the source stores.
When a source goes away #
Because the store is a separate process, it can crash or restart. When a source goes down its hives become unavailable: open key handles stay valid (they hold identity and a granted mask, neither of which needs the store), but operations that need to reach the store return an error until it is back. Watches stay armed across the outage, and when the source re-registers, watchers receive an overflow so they re-read and resynchronise. The registry treats a store restart as a disruption to recover from, not a reason to lose state.
Where to go next #
You have now seen the whole model — data, meaning, layers, security, change notification, and the parts underneath.
For the tool you will reach for most when configuring a system — looking up what any key or value means — read The registry manual (regman).
Three advanced topics remain:
For grouping several writes into one all-or-nothing change — how a role installs without ever being half-applied — read Transactions.
For complete per-caller isolation — hives and layers visible only to one sandboxed process — read Private hives and layers.
For keys that point at other keys — the one place the registry follows a value — read Registry links.
The registry manual (regman)
Peios / Using Peios / Administration
Configuration, not storage established that the registry holds values but not their meaning: it keeps a type tag and some bytes, and never knows that BufferCapacity must be a power of two or what it is for. That knowledge has to live somewhere a person can look it up. It lives in regman, the registry's manual.
regman is the man of the registry. Give it a path and it tells you what a key or value actually is. It is the everyday tool for configuring a Peios system — before you touch a knob, regman is how you find out what it does and what changing it will cost.
regman, in one sentence #
regman <path> [value] documents what a registry key or value means — its type, default, valid values, when a change takes effect, and a prose description — drawn from shipped documentation, never from the live registry.
That last clause is the one to hold onto; there is a section on it below.
Looking up a value #
Give regman a key path and a value name and it prints a knob-card — an identity line, an aligned block of facts, and a description:
$ regman Machine\System\KMES BufferCapacity
Machine\System\KMES BufferCapacity documented by kmes
Type REG_QWORD
Default 4194304 (4 MB)
Valid 65536–268435456 bytes (64 KB–256 MB), power of two
Applies live — ring-buffer swap
Per-CPU ring buffer capacity, in bytes. Must be a power of two; values
that are not are treated as invalid and ignored.
Every registry value is a knob, and every knob has the same few facts worth knowing, so the card is uniform rather than free-form:
| Field | Tells you |
|---|---|
| Type | The value's registry type (REG_QWORD, REG_SZ, …). |
| Default | The value used when nothing is set. |
| Valid | The range, set, or constraint a sensible value must satisfy. |
| Applies | When a change takes effect — live, on restart, or on reboot. |
The Applies field is the one operators reach for most: it is the difference between "edit it and you're done" and "edit it and schedule a reboot". Only the fields that make sense for a given item are shown, and a knob that is being retired carries a prominent deprecation notice at the top of its card.
Looking up a key #
Give regman just a key — no value — and it does double duty as an index: the key's own description, then a list of every value documented under it, one summary line each.
$ regman Machine\System\KMES
Machine\System\KMES documented by kmes
The KMES event subsystem reads its tuning parameters from the values
under this key...
Values
BufferCapacity Per-CPU ring buffer capacity in bytes.
MaxEventSize Max total size of a userspace-emitted event.
MaxNestingDepth Max nesting depth for userspace event payloads.
MaxEmitRatePerProcess Max events per second a single process may emit.
So you can start at a subtree, see what is configurable there, and drill into any single value.
Searching, when you do not know the path #
If you don't know where a setting lives, regman -k searches names and summaries — the registry's apropos:
$ regman -k buffer
Machine\System\KMES BufferCapacity Per-CPU ring buffer capacity in bytes.
regman documents intent, not current state #
This is the boundary that matters most. regman tells you what a setting should be — what it means and which values are legal — not what it is currently set to on this machine. It reads shipped documentation, never the live registry; it does not talk to LCS at all. Ask regman about BufferCapacity and you learn it must be a power of two and defaults to 4 MB; you do not learn that this particular box has it set to 8 MB right now. Reading the current value is a separate, live query against the registry — that is reg's job. regman tells you a knob should be a power of two; reg get tells you what it is set to, and reg set changes it. Reach for regman to decide what to write, and reg to write it.
Put regman next to the other two things from Configuration, not storage and a clean division of labour appears:
| To learn… | Look at… |
|---|---|
| What a setting means, and what is valid | regman — the manual |
| What the value is set to | reg — a live query against the store |
| What the subsystem is actually running on | the event log |
regman is the one you consult first, and the only one that works before you have changed anything — because documentation ships with the software, not with the machine's state. It is the natural complement to reject-or-keep: regman tells you the valid range up front, so you set a good value the first time instead of writing one the owning subsystem will quietly refuse.
Where the documentation comes from #
regman has no built-in database of every setting. Each package ships its own documentation as files dropped into a well-known directory (/usr/share/regman/), one fragment per package documenting that package's whole configuration surface. Install a package and its registry documentation appears; remove the package and it goes away. The package that owns a set of keys is the package that documents them — the only arrangement that stays correct as the system changes.
A consequence worth knowing: regman can only describe settings that some installed package has documented. An undocumented key is invisible to it — regman is exactly as complete as the packages on the machine make it. If two packages happen to document the same item, regman shows both and flags the overlap rather than silently picking a winner.
(For the people who write that documentation, regman fmt and regman lint prepare and check fragments, and regman index keeps lookups fast on large corpora — the lookup is always correct without an index, which is purely an accelerator. These are packaging concerns rather than everyday operator ones.)
Where to go next #
For the idea regman exists to serve — why the registry holds values without holding their meaning, and what reject-or-keep means — read Configuration, not storage.
For the other half of the picture — reading what a value is actually set to, which is a live query against the store rather than a documentation lookup — read LCS and sources.
reg
Peios / Using Peios / Administration
reg is the command-line interface to the live registry — the running LCS store, reached through the registry system calls. Where regman reads shipped documentation and tells you what a key means, reg talks to the kernel and reads or writes what a key is. It is the everyday tool for scripting configuration: querying effective values, writing to layers, masking and hiding, managing security descriptors, watching for change, and taking backups.
reg <command> [options] <key> [value] [data]
reg is the read/write half of the registry toolset and regman is the lookup half. The division is exact:
| To… | Use… |
|---|---|
| Read or change what a value is set to, on this machine, right now | reg |
| Look up what a value means — its type, default, valid range, when it applies | regman |
Consult regman first to learn the legal range for a knob, then use reg set to write a value inside it. reg never checks a value's meaning and regman never touches the live store; they are complementary.
How reg treats access and privilege #
Every reg operation goes through the kernel's access check against the single key it opens — the same access-control path as any other protected object, with no traversal check on the parent keys. reg performs no identity or membership checks of its own: it does not refuse a privileged operation up front. It attempts the operation and reports faithfully whatever the kernel returns. An operation that needs a privilege you lack (creating a link, a positive-precedence layer, a backup or restore, reading a SACL) simply fails with access denied (exit 3) rather than being blocked by the tool. A single reg command may legitimately span privilege tiers.
Addressing model #
Almost every subcommand shares one addressing scheme: a key path positional, and an optional value name positional. Understanding the split is most of understanding reg.
Key paths #
The key is a positional path argument. Both / and \ are accepted as separators — neither character is legal inside an LCS key component, so there is no ambiguity, and you may use whichever your shell quotes most comfortably:
reg get Machine/System/KMES
reg get 'Machine\System\KMES' # identical
A leading separator is optional and ignored (/Machine/X is the same as Machine/X). Paths are compared case-insensitively. The first component is the hive:
| Path | Meaning |
|---|---|
Machine\… | The machine hive — system-wide configuration. |
Users\<SID>\… | A specific principal's hive. |
CurrentUser\… | A kernel alias, rewritten to the caller's own Users\<SID>\ at the syscall boundary. |
On output, reg displays paths with a backslash by default. --sep=/ (or REG_SEP=/) switches display to forward slash for that invocation; this affects display only, never how a path is parsed.
The value-name positional #
The value name is a separate positional argument — it is never folded into the key path. This is deliberate: an LCS value name may itself contain / or \ (which a key component may not), so keeping them apart removes all ambiguity.
reg get Machine/System/KMES BufferCapacity
# └──── key path ─────┘ └── value ──┘
The presence or absence of the value argument selects what the command targets:
- No value argument ⇒ the command targets the key itself — its metadata, its values as a set, its security descriptor, its children.
- A value argument ⇒ the command targets that one named value.
- The default value (the empty-name value) is addressed by the literal token
@in the value-name position, mirroring.regconvention:reg get Machine\App @.
This split applies uniformly to get, set, del, mask, and unmask.
Value literals and types #
On set (and in a batch), the value's data is a single token whose registry type is either forced by a type: prefix or inferred from its shape. The registry value types and their literal syntax:
| Prefix | Registry type | Data syntax |
|---|---|---|
sz: | REG_SZ | UTF-8 string (rest of token, verbatim). |
expand: | REG_EXPAND_SZ | UTF-8 string, %VAR% left unexpanded on disk. |
dword: | REG_DWORD | 42 or 0x2A; must fit u32. |
dword-be: | REG_DWORD_BIG_ENDIAN | 42 or 0x2A; must fit u32. |
qword: | REG_QWORD | 42 or 0x2A; must fit u64. |
multi: | REG_MULTI_SZ | Comma-separated; \, escapes a literal comma. |
hex: / bin: | REG_BINARY | Hex bytes; :, -, and space separators are ignored. |
link: | REG_LINK | An absolute key path (a symlink target). |
none: | REG_NONE | No data (token must be empty after the prefix). |
When the substring before the first : is not a recognised keyword (for example http://host), the token is not treated as typed and inference applies:
| Token shape | Inferred type |
|---|---|
all decimal digits, fits u32 | REG_DWORD |
all decimal digits, fits u64 but not u32 | REG_QWORD |
0x… hex, ≤ 8 significant hex digits | REG_DWORD |
0x… hex, ≤ 16 significant hex digits | REG_QWORD |
| anything else (including empty) | REG_SZ |
Inference is intentionally broad, so any all-digit token becomes a number — a PIN, a zip code, or 007 becomes a REG_DWORD and loses its textual form. To force a string, prefix it with sz: (sz:007 stores the string 007; sz:dword:42 stores the literal string dword:42). Because the coercion is a known trap, reg set always echoes the resolved type on success, so a surprising conversion is never silent even under --quiet.
Global options #
Every subcommand accepts the following. Each behavioural toggle also has an environment-variable equivalent, so it can be set per-invocation (the flag) or once per shell or script (the variable). The flag wins over the variable, which wins over the built-in default.
| Option | Env var | Effect |
|---|---|---|
--json | REG_JSON=1 | Emit structured JSON instead of human text. watch emits JSON-lines. |
-v, --verbose | REG_VERBOSE=1 | More detailed output. |
-q, --quiet | — | Suppress non-essential output. (A surprising type coercion is still reported.) |
--sep=CHAR | REG_SEP | Path display separator: \ (default) or /. |
--help | — | Print help for the command and exit 0. |
--version | — | Print the version and exit 0. |
Mutating subcommands additionally accept:
| Option | Env var | Effect |
|---|---|---|
--layer NAME | REG_LAYER | Target layer for the write (default: the base layer). |
-y, --yes | REG_ASSUME_YES=1 | Skip the confirmation prompt on a destructive command. |
Destructive commands (del -r, restore, layer del) prompt for confirmation when standard input is a terminal, and auto-proceed when it is not — so interactive use is guarded while scripts are never blocked. Set -y/--yes or REG_ASSUME_YES=1 to skip the prompt explicitly.
Reading #
reg get <key> [value] #
Read the effective (resolved) value. With a value, prints that value's data; with no value, lists the key's effective values (the default @ value first, then named values sorted case-insensitively) — a shorthand for ls --values-only.
| Option | Effect |
|---|---|
-L, --layers | Annotate the value with the layer it resolved from and its sequence number. |
--raw | Write the value's raw bytes to standard output verbatim — for piping REG_BINARY data. |
--no-follow | Operate on a symlink key itself rather than following it to its target. |
The single-value default form prints data bare (no quotes; one element per line for REG_MULTI_SZ) so it pipes cleanly. -L shows the winning layer's provenance:
$ reg get Machine\System\KMES BufferCapacity
4194304
$ reg get Machine\App Theme -L
Theme = REG_SZ "dark" (layer: policy, seq 7)
Only the winning value is shown; the full shadowed stack beneath it is not yet exposed by any client ABI (the -L flag will render the whole stack unchanged once that primitive lands). See Layers for what "effective" resolves against.
reg ls <key> #
One-level listing of a key's immediate subkeys and values.
| Option | Effect |
|---|---|
-l, --long | Long form: for subkeys, child and value counts; for values, type and byte size. |
--keys-only | List subkeys only. |
--values-only | List values only. |
$ reg ls Machine\System\KMES -l
BufferCapacity = REG_QWORD 4194304 (8 bytes)
MaxEventSize = REG_DWORD 65536 (4 bytes)
reg tree <key> #
Recursively list the subkey tree rooted at key.
| Option | Effect |
|---|---|
--depth N | Limit recursion to N levels. |
--values | Include each node's values, not just its keys. |
$ reg tree Machine\System --depth 2
reg info <key> #
Print a key's metadata: leaf name, subkey and value counts, last-write time, hive generation, security-descriptor size, and the volatile and symlink flags.
| Option | Effect |
|---|---|
--no-follow | Inspect a symlink key itself rather than its target. |
$ reg info Machine\System\KMES
path Machine\System\KMES
name KMES
subkeys 0
values 4
last_write_ns 1717243800000000000
hive_generation 42
sd_size 164
volatile false
symlink false
Writing #
reg set <key> <value> <data> #
Create or update a value. The value name (or @) and the data token are both required. Writes are tagged with --layer (default: base). See value literals for the data syntax.
| Option | Effect |
|---|---|
--layer NAME | Write into layer NAME instead of the base layer. |
-p, --parents | Create any missing ancestor keys. |
--expected-seq N | Compare-and-swap guard: apply only if the value's current sequence is N; a mismatch exits 6 without writing. |
$ reg set Machine\App Build 4096
set Machine\App Build = REG_DWORD 4096 (layer: base)
$ reg set Machine\App Servers multi:alpha,beta --layer policy
--expected-seq supports lock-free read-modify-write: read the value with -L to learn its sequence, then write back with --expected-seq set to that number; if another writer changed it in between, your write fails cleanly instead of clobbering theirs.
reg new <key> #
Create a key with no values. Reports whether the key was created or already existed.
| Option | Effect |
|---|---|
--layer NAME | Create the key in layer NAME. |
-p, --parents | Create any missing ancestor keys. |
--volatile | Create a volatile (RAM-only) key that does not survive a reboot. |
$ reg new Machine\App\Cache --volatile
created Machine\App\Cache (layer: base)
reg del <key> [value] #
Delete a value, or a key. (Alias: delete.)
With a value, deletes this layer's entry for that value only — lower-layer entries resurface, because a per-layer delete is not a tombstone (use mask for that). With no value, deletes the key, which must be empty unless -r is given.
| Option | Effect |
|---|---|
--layer NAME | Delete from layer NAME. |
-r, --recursive | Delete the key and all its descendants (walks and removes children). |
-y, --yes | Skip the confirmation prompt (recursive deletes prompt on a terminal). |
$ reg del Machine\App Legacy
deleted value Legacy from Machine\App
$ reg del Machine\App\Temp -r
Recursively delete key Machine\App\Temp and all its contents? [y/N] y
deleted Machine\App\Temp (3 keys)
See Deleting keys and values for how a per-layer delete differs from a tombstone.
Masking and hiding #
These commands expose LCS's tombstone and hide primitives, which mask lower-precedence state rather than removing a layer's own entry — the distinction that makes layers revertible. The layer is chosen with --layer (default: base).
reg mask <key> [value] / reg unmask <key> [value] #
mask sets a tombstone: a per-value marker in the target layer that hides that value from all layers below it. unmask clears it.
| Option | Effect |
|---|---|
--layer NAME | The layer that carries the tombstone. |
--all | Blanket tombstone: mask all lower-layer values on the key (no value argument). |
A value is required unless --all is given.
$ reg mask Machine\App ApiKey --layer policy
set tombstone on Machine\App ApiKey (layer: policy)
$ reg mask Machine\App --all --layer policy
set blanket tombstone on Machine\App (layer: policy)
reg hide <key> / reg unhide <key> #
hide installs a HIDDEN path entry in the target layer, masking the key's existence in that layer and below. unhide removes that layer's path entry, letting the key reappear.
| Option | Effect |
|---|---|
--layer NAME | The layer that hides (or unhides) the key. |
$ reg hide Machine\App\Debug --layer policy
hid Machine\App\Debug (layer: policy)
Layers #
reg layer ls #
List layers with name, precedence, enabled state, and (informational) owner SID, ordered by descending precedence.
| Option | Effect |
|---|---|
-l, --long | Long form. |
$ reg layer ls
policy prec=100 enabled owner=S-1-5-32-544
base prec=0 enabled
reg layer new <name> #
Create a layer by writing its metadata key under Machine\System\Registry\Layers\.
| Option | Effect |
|---|---|
--precedence N | Precedence (higher wins). A precedence above 0 requires SeTcbPrivilege; the tool attempts it and reports any denial. |
--owner SID | Record an informational owner SID. |
--disabled | Create the layer disabled. |
$ reg layer new policy --precedence 100 --owner S-1-5-32-544
created layer policy (precedence 100, enabled)
reg layer set <name> #
Modify an existing layer's metadata.
| Option | Effect |
|---|---|
--precedence N | Change the precedence. |
--enable / --disable | Enable or disable the layer. |
--owner SID | Change the informational owner SID. |
reg layer del <name> #
Delete a layer — removes its metadata key; LCS tears down the layer's entries. Prompts for confirmation on a terminal.
$ reg layer del policy
Delete layer policy and all its entries? [y/N] y
deleted layer policy
Managing per-process private layer views is out of scope for reg; see private hives and layers.
Security descriptors #
reg sd <key> #
Show or set a key's security descriptor as SDDL, using the same codec as the sd tool, so its output is consistent. With no scope flags, the default is owner + group + DACL (a SACL requires the privileged ACCESS_SYSTEM_SECURITY right).
| Option | Effect |
|---|---|
--set SDDL | Apply the given SDDL. Without it, sd prints the current descriptor. |
--owner | Scope to the owner. |
--group | Scope to the group. |
--dacl | Scope to the DACL. |
--sacl | Scope to the SACL (needs ACCESS_SYSTEM_SECURITY). |
$ reg sd Machine\App
O:BAG:BAD:(A;;KA;;;BA)(A;;KR;;;WD)
$ reg sd Machine\App --set 'O:BAG:BAD:(A;;KA;;;BA)' --owner --dacl
set security descriptor on Machine\App
A security-descriptor change takes effect on future opens only (existing handles keep the access mask they were granted). Security changes are not layered and are not undone by layer removal — they mutate the key object directly. See Access control on keys.
Symlinks #
reg link <key> <target> #
Create a symlink key pointing at the absolute target key path. This creates a key with the immutable symlink flag and a default REG_LINK value holding the target. Requires KEY_CREATE_LINK and SeTcbPrivilege/Administrator; the tool attempts it and reports any denial.
| Option | Effect |
|---|---|
--layer NAME | Create the link key in layer NAME. |
$ reg link Machine\App\CurrentConfig Machine\App\Config\v3
linked Machine\App\CurrentConfig -> Machine\App\Config\v3 (layer: base)
get and info follow symlinks to their target by default; pass --no-follow to inspect the link key itself. See Advanced: registry links.
Watching #
reg watch <key> #
Arm a change watch and stream one event per change until interrupted (or until --count events). Each event faithfully means "something under the filter changed — re-read"; reg does not decode the change records' contents. With --json, emits one JSON object per line.
| Option | Effect |
|---|---|
--subtree | Watch descendant keys too, not just this key. |
--filter LIST | Comma-separated subset of value, subkey, sd (default: all). |
--count N | Exit after N events. |
$ reg watch Machine\System\KMES --subtree --filter value
changed: Machine\System\KMES
changed: Machine\System\KMES
See Watching for changes for why a service watches instead of polling.
Batch, export, backup, and restore #
Two paths move a subtree around: export/apply are the portable, reviewable path (a diffable text or JSON document); backup/restore are the exact, privileged path (an opaque kernel snapshot).
reg apply <file> #
Apply a batch of operations to one hive in a single transaction — all-or-nothing. All operations must target one hive (a cross-hive batch fails with exit 4). - reads standard input.
| Option | Effect |
|---|---|
--dir DIR | Apply every batch file in DIR (sorted), each as its own transaction. A missing or empty directory applies nothing and succeeds. |
--once-delete | Delete each batch file after it applies successfully (a drain). |
-y, --yes | Skip confirmation. |
The batch format is JSON (canonical and exact) or a line-oriented text format; apply auto-detects (a leading { or [ means JSON). Text-format input is not yet implemented — supply JSON, or generate one with reg export --json. Text export works for review.
$ reg export --json Machine\App - | reg apply -
applied 4 keys
reg export <key> [file] #
Dump a subtree to the batch format. Omit file (or pass -) to write to standard output. Human-readable text by default; --json emits the canonical exact form.
| Option | Effect |
|---|---|
--layer NAME | Export one layer's view (default: the effective state). |
$ reg export Machine\App app-config.reg
reg backup <key> <file> #
Write an opaque, exact, binary kernel snapshot of a key and its subtree to file. Requires SeBackupPrivilege.
$ reg backup Machine\System\KMES kmes.snap
backed up Machine\System\KMES -> kmes.snap
reg restore <key> <file> #
Replace a key and its entire subtree from a snapshot, in one transaction. Requires SeRestorePrivilege. Prompts for confirmation on a terminal.
$ reg restore Machine\System\KMES kmes.snap
Replace key Machine\System\KMES and its entire subtree from kmes.snap? [y/N] y
restored Machine\System\KMES from kmes.snap
See Backup and restore for when to reach for each path.
Output formats #
Default output is terse, human-readable, and grep-friendly. --json switches every command to structured output — a single self-contained JSON document, except watch, which emits JSON-lines (one object per event). In any value listing, the default (@) value is emitted first, then named values sorted case-insensitively.
Type formatting on read:
| Registry type | Human form | JSON form |
|---|---|---|
REG_SZ / REG_EXPAND_SZ | the string | {"type":"sz","data":"…"} |
REG_DWORD / REG_QWORD | decimal | {"type":"dword","data":42} |
REG_MULTI_SZ | one element per line | {"type":"multi","data":["a","b"]} |
REG_BINARY | hex | {"type":"binary","data":"deadbeef"} |
REG_LINK | the target path | {"type":"link","data":"…"} |
Exit status #
| Code | Meaning | Typical cause |
|---|---|---|
0 | Success. | — |
1 | Usage error. | Bad arguments; a required option missing. |
2 | Key or value not found. | ENOENT. |
3 | Access denied. | EACCES, EPERM — the kernel refused the operation. |
4 | Invalid specification. | Bad path, type, or literal; a cross-hive batch; a name too long (EINVAL, EXDEV, ENAMETOOLONG). |
5 | Syscall or source failure. | EIO, ETIMEDOUT, ENOSPC, ENOMEM. |
6 | Compare-and-swap conflict. | EAGAIN — an --expected-seq guard did not match; the value changed under you. |
A denial (3) reports the operation, the target, and, where relevant, the access that was required — in the style of the sd and token tools.
See also #
regman— the registry manual: what a key or value means, as opposed to what it is set to. Consult it before youreg set.- Keys, values, and types — the data model
regaddresses. - Layers — what "effective" resolves against, and what
--layer,mask, andhideoperate on. - Access control on keys — the check every
regoperation goes through.
Private hives and layers
Peios / Using Peios / Advanced
Everything in the core topic describes one shared registry that every process sees the same way (subject to access control). Private hives and private layers relax that: they let a particular caller see a registry that differs from everyone else's — the building block for sandboxing and container-style isolation.
This is an advanced feature, and a partly forward-looking one. The registry defines how a private hive or layer participates in resolution; but who is allowed to see one is decided by a thread's credentials, and that credential model is part of KACS and not yet fully specified. Treat this page as the shape of the feature, not a how-to.
Private hives #
A private hive is a hive that is visible only to threads whose credentials carry a matching scope — an opaque identity that marks "you are allowed to see this hive". To everyone else it does not exist.
The powerful case is shadowing. A private hive can take the same name as a global one — Machine\, say — and for a thread that carries the scope, the private hive is what Machine\ resolves to, not the global one. A sandboxed process can therefore be given an entirely separate Machine\ while every other process continues to see the real one, with no change to the paths anyone uses. That is complete registry isolation without a parallel namespace: same paths, different contents, decided by who is asking.
Private layers #
A private layer is a layer that is globally disabled — invisible in normal resolution — but attached to a specific thread's credentials. For that thread, and only that thread, the layer is treated as active and competes in the per-value contest at its precedence exactly like any other layer. Everyone else resolves as if it were not there.
This is the lighter-weight tool, for when you want a different value here and there rather than a whole separate hive:
- Per-session overrides — run one application with experimental settings without disturbing other sessions.
- Testing — inject configuration for one process without writing it into the shared registry.
- Sandboxing — give a confined process a slightly different view without standing up a private hive.
Private layers are per thread, not per process: because the attachment rides on a thread's credentials, different threads in the same process can carry different private layers (for instance, a service thread acting on behalf of a particular client). The number a thread can carry is bounded.
Authorisation lives in KACS #
The registry defines only the resolution behaviour — private hives are checked ahead of global ones, and a thread's private layers fold into its contests. The decision about whether a thread may join a scope or attach a layer belongs to KACS's credential model. One constraint is already clear and worth stating: attaching a layer that sits above others in precedence must be privileged, or an unprivileged process could attach an existing high-precedence policy layer to itself and gain a window onto configuration it should not see or influence. The isolation is only as strong as the rule that decides who may carry a scope or a private layer.
Where to go next #
For the resolution model these build on — precedence, recency, and the per-value contest — read Layers.
For where private hives are routed and served, read LCS and sources.
Transactions
Peios / Using Peios / Advanced
Most registry writes stand alone. But sometimes a set of changes only makes sense together — a role's keys and values are one coherent configuration, and a half-written version would be worse than none at all. A transaction makes several writes atomic: all of them commit, or none do. This is a developer-facing feature — you reach for it when writing to the registry, not when operating a running system — which is why it sits among the advanced topics.
Transactions, in one sentence #
A transaction groups many registry writes into one all-or-nothing commit, so other readers only ever see the complete change, never a partial one.
All or nothing #
Inside a transaction you perform a series of writes and then commit. At commit, they all take effect together. If you abort instead — or the process dies, or the transaction sits open too long and times out — none of them take effect. There is no partial application.
External readers see only committed state. Until you commit, your in-progress writes are invisible to everyone else; the instant you commit, the whole set appears at once. A service reading configuration never catches a transaction halfway. Within your own transaction you do see your own pending writes, so you can write a value and read it back before committing.
The canonical use: installing a role #
This is why role installation is clean. A role's entire configuration — its keys, its values, its layer — is written inside one transaction and committed as a unit. The role is never observed half-installed: either the whole role is there or none of it is. (Removing a role is the other mechanism — deleting its layer — covered with layers.)
Any consistent multi-key update wants the same treatment: change several related settings as one atomic step, rather than letting readers see an inconsistent in-between.
Limits worth knowing #
- One store at a time. A transaction is scoped to a single hive's store; it cannot span hives backed by different sources. Atomicity across separate stores is not offered.
- No nesting. Transactions are flat — there are no sub-transactions or savepoints.
- Bounded lifetime. An open transaction holds a write position, so it cannot be left open indefinitely; if it is not committed in time it is aborted automatically. This stops a stalled or abandoned writer from blocking everyone else's writes to that store.
- Abandonment is safe. Closing the handle without committing aborts cleanly, and a process dying does the same — there are no orphaned transactions left holding things up.
Transactions and layers #
A transaction provides atomicity; it is not a layer and it does not change which write wins. The two compose: a role install uses a transaction to apply its writes atomically and a layer to make them removable later. Keep them distinct — atomicity is "all together", layering is "which one wins, and how to revert".
Transactions are not conflict-detected at the value level. If two committed transactions wrote the same value, both succeed, and the usual rule settles it: the more recent write wins, exactly as in layer resolution. A transaction guarantees its own writes land together; it does not lock anyone else out of the values it touched. (For the case where you must not clobber a concurrent change, a conditional write lets a single write proceed only if the value has not changed since you read it.)
Where to go next #
For how a role uses a transaction and a layer together, read What layers are for.
For the recency rule that settles competing writes, read Layers.
Registry links
Peios / Using Peios / Advanced
A registry key can be a symbolic link to another key. Open a link key by its path and the registry follows it through to the target, handing you a handle on the target key. Links let one part of the namespace point at another, the way a filesystem symlink does.
Links are an advanced feature with a small, sharp set of rules, and they are the one exception to a property the rest of the topic relies on.
A link is a flag plus a target value #
Two pieces make a link, and both are needed:
- The key is marked as a link when it is created. This is a fixed property of the key.
- The key's default value, of type
REG_LINK, holds the target path — an absolute registry path the link points at.
When path resolution reaches a link key, the registry reads that target and continues resolving from there; the handle you get back refers to the target, not the link.
The one place the registry reads a value #
Keys, values, and types made a point of it: the registry stores a value's type and bytes but never interprets them. REG_LINK is the single exception. It is the one type the registry acts on itself — following it during path resolution — rather than handing it back untouched. Everything else remains opaque; links are the lone case where the store cares what a value says.
Managing the link itself #
Normally you want to follow a link. To operate on the link key itself — to change where it points or delete it — you open it with a flag that says "open the link, do not follow it". Without that flag every open lands on the target, which would make the link impossible to manage.
Creating a link is privileged #
Because a link silently redirects whoever opens it, creating one is restricted. It requires the KEY_CREATE_LINK right on the parent and a privileged caller (the system-trust privilege, or Administrator membership). A link is a small piece of trusted plumbing, not something an ordinary process gets to introduce into a path other callers will traverse.
There is one safety rule worth knowing: a link target is followed literally, and the CurrentUser\ convenience alias is not expanded inside it. This stops a link from redirecting a privileged service that resolves CurrentUser\ into the service's own user hive — a classic confused-deputy trap. Link targets route by their literal hive name. Resolution is also bounded by a hop limit, so a cycle of links fails rather than looping forever.
Layers can redirect a link #
The target is an ordinary layered value — the link key's default value — so it plays by the same rules as any other value. A higher-precedence or more-recent layer can write a different REG_LINK target and redirect the link; remove that layer and the original target resurfaces, by the usual automatic revert. And if a layer writes a default value that is not a REG_LINK onto a key that is still flagged as a link, resolution through it fails until the offending layer is removed or overridden. The link's identity is fixed at creation; its target is just configuration, and configuration is layered.
Where to go next #
For the opaque-value rule this is the exception to, read Keys, values, and types.
For how a layer can redirect or break a link, read Layers.
Files and directories
Peios / Using Peios / Peiosutils / Files and directories
This topic covers the commands that change the file system: creating files and directories, copying and moving them, removing them, and adjusting their size. If a command brings a file into existence, takes one out of it, or duplicates one, it is here.
This page names every command, and then explains the one theme that runs through the whole topic on Peios: what happens to a file's security descriptor when the file is created or copied.
The commands #
| Command | Purpose |
|---|---|
cp | Copy files and directories. |
mv | Move or rename files and directories. |
rm | Remove files, and with -r, directories. |
rmdir | Remove directories, but only empty ones. |
mkdir | Create directories. |
mkfifo | Create named pipes (FIFOs). |
mknod | Create special files — device nodes and FIFOs. |
touch | Create empty files, or update a file's timestamps. |
ln | Create hard links and symbolic links. |
link and unlink | The single-purpose, low-level versions of "make a hard link" and "remove a file". |
shred | Destroy a file's contents by overwriting them, so they cannot be recovered. |
dd | Copy data block by block, converting it on the way. |
df | Report how much space each file system has, and how much is free. |
du | Report how much space files and directories use. |
truncate | Shrink or extend a file to an exact size. |
mktemp | Create a temporary file or directory with a safely unique name. |
Every file has a security descriptor #
On Peios, every file and directory carries a security descriptor — the record of who owns it and who may do what to it. Access to a file is decided from its descriptor; see Security descriptors for the full picture.
That fact shapes two groups of commands in this topic.
Creating a file gives it a descriptor #
When mkdir, mkfifo, mknod, or touch creates a new object, that object needs a security descriptor. By default it gets one inherited from the directory it is created in — the new object picks up the access rules its parent directory hands down.
All four of these commands share one set of options — the creation flags — that let you set the new object's descriptor explicitly instead of taking the inherited default: name an owner, set a different group, lock the object so it does not inherit, give it an integrity label, or supply a complete descriptor. The flags are documented in full on the mkdir page; mkfifo, mknod, and touch each link back to it.
Copying a file makes a fresh descriptor — unless you preserve #
cp creates new files, and a cross-file-system mv does too. A brand-new copy gets a brand-new descriptor inherited from its destination directory — it does not carry the source file's security across by default.
When you do want the copy to keep the source's owner, access rules, timestamps, or other attributes, that is the job of the --preserve family of options. cp and mv share an identical --preserve surface; it is documented on the cp page.
Removing a file checks whether you may write it #
rm and shred both make a decision based on whether you can write a file. rm prompts before removing a file you cannot write to; shred's --force overrides a file's protection so it can be destroyed. Both judge "can you write this?" by a live access check against the file's security descriptor — not by any decorative metadata on the inode. Each command's page explains exactly where that check is made.
Where to start #
For copying — and for the whole --preserve model — read cp.
For creating files and directories, and the shared creation flags, read mkdir.
For removing things safely, read rm.
cp
Peios / Using Peios / Peiosutils / Files and directories
cp copies files and directories.
cp [options] source dest
cp [options] source... directory
cp [options] -t directory source...
In the first form, cp copies one source to one dest. In the other two, it copies any number of sources into a directory, keeping their names.
$ cp report.txt report-backup.txt
$ cp report.txt notes.txt /home/jack/archive/
Copying directories #
cp refuses to copy a directory unless you ask it to descend into one:
| Option | Effect |
|---|---|
-r, -R, --recursive | Copy directories, and everything inside them, recursively. |
-a, --archive | A faithful recursive copy: copy the whole tree, follow no symbolic links, and preserve every attribute. Equivalent to -d -R --preserve-all. |
-a is the option to use when you want the copy to be as close to the original as possible — same structure, same security, same timestamps. A plain -r copies the contents but, as the next section explains, gives the copies fresh security of their own.
What a copy's security descriptor is #
This is the part of cp that is specific to Peios, so it is worth being precise.
Every file carries a security descriptor — the record of who owns it and who may do what to it. When cp copies a file, it creates a new file, and that new file needs a descriptor.
By default, a copy gets a fresh descriptor — it does not inherit the source's. Specifically, a plain cp:
- gives the copy a descriptor inherited from the destination directory, exactly as if you had created a new file there;
- makes you the owner of the copy;
- gives the copy fresh timestamps — the copy's modification time is "now".
This is usually what you want. A file copied into your home directory should be governed by your home directory's rules and owned by you, regardless of where it came from. But when you need the copy to keep something from the source, you ask for it explicitly — with the --preserve family.
The --preserve family #
--preserve carries chosen attributes from the source onto the copy. The attributes you can preserve:
| Attribute | What it carries from the source |
|---|---|
owner | The owner SID. |
dacl | The whole DACL — every access rule on the file, inherited rules included. |
sacl | The whole SACL — the audit rules and the integrity label. |
daclni | The DACL with inherited rules stripped out — only the rules set explicitly on the source. |
saclni | The SACL with inherited rules stripped out. |
timestamps | The access and modification times. |
links | The hard-link structure — files hard-linked together in the source stay hard-linked in the copy. |
security | The security.peios.* extended attributes (other than the descriptor itself). |
xattrs | All other extended attributes. |
You rarely name those individually. These options select sensible sets:
| Option | Preserves |
|---|---|
-p | Timestamps only. |
--preserve | Timestamps only — the same as -p. |
--preserve=LIST | Exactly the comma-separated attributes you name. |
--preserve-all | Every attribute in the table above. |
--sd | owner, dacl, sacl — the full security descriptor, copied verbatim. |
--sd-explicit | owner, daclni, saclni — the source's explicitly set rules only. |
--no-preserve=LIST | Turns the named attributes back off — useful to subtract from a broader option. |
--sd versus --sd-explicit #
Both carry the source's security across; they differ in how they treat inherited rules.
--sdcopies the descriptor exactly — inherited rules and all. The copy ends up with precisely the access rules the source had, even if it lands in a directory that would have handed down something different. Use it when the copy must be a security-exact duplicate.--sd-explicitcopies only the rules that were set explicitly on the source, and lets the destination directory supply inheritance for the rest. Use it when copying a file into a different directory and you want the file's own tailored rules to come along while still picking up the new location's inherited rules.
For what "inherited" versus "explicit" means, see Inheritance.
When a preserve cannot be honoured #
A requested preserve is a firm instruction. If cp cannot carry an attribute across — for example, writing the copy's SACL requires the SeSecurityPrivilege privilege, and the caller does not have it — cp treats that as a hard error and the copy fails. It does not quietly produce a copy that is missing what you asked to preserve.
There is one exception, and it is a matter of definition rather than a softening of the rule. security and xattrs name extended attributes, and some filesystems have no extended attributes at all — FAT, 9p, and a squashfs image built without an attribute table are the ones you will meet. Copying from such a filesystem carries nothing, because there was nothing there: the preserve is satisfied vacuously, and the copy succeeds.
That is narrow on purpose. It applies only to reading the source. If the source does have extended attributes and the destination filesystem cannot hold them, the preserve genuinely failed and cp still stops:
# cp -a /home/jack/notes /mnt/usb-fat/
cp: failed to set extended attributes on '/mnt/usb-fat/notes': Operation not supported
The distinction matters most for -a, which requests both attribute classes on every file it touches. Without it, one xattr-less filesystem anywhere in a tree would abort the whole copy partway through.
Overwriting an existing destination #
By default, if the destination already exists, cp overwrites it. These options change that.
| Option | Effect |
|---|---|
-i, --interactive | Ask before overwriting an existing file. |
-n, --no-clobber | Never overwrite an existing file; skip it silently. |
-u, --update[=WHICH] | Overwrite only when the source is newer than the destination. WHICH can be all, none, or older. |
-f, --force | If an existing destination cannot be opened for writing, remove it and try the copy again. |
--remove-destination | Remove the destination before opening it, always — contrast --force, which only removes on failure. |
-b, --backup[=CONTROL] | Before overwriting, make a backup of the destination. |
Symbolic links in the source #
When a source is a symbolic link, cp can copy the link itself or the file it points to.
| Option | Effect |
|---|---|
-L, --dereference | Always follow symbolic links — copy the file they point to. |
-P, --no-dereference | Never follow symbolic links — copy the link itself. |
-H | Follow only the symbolic links named directly on the command line, not links found while recursing. |
-d | Copy links as links, and preserve hard-link structure. Short for --no-dereference --preserve=links. |
Other ways to copy #
cp can also produce something other than a plain duplicate.
| Option | Effect |
|---|---|
-l, --link | Create a hard link to the source instead of copying its data. |
-s, --symbolic-link | Create a symbolic link to the source instead of copying. |
--reflink[=WHEN] | Make a lightweight copy-on-write clone where the file system supports it. WHEN is always, auto, or never. |
--sparse[=WHEN] | Control whether runs of zero bytes are stored as holes rather than written out. WHEN is always, auto, or never. |
--attributes-only | Create the destination and copy its attributes, but not its data. |
Other options #
| Option | Effect |
|---|---|
-t, --target-directory=DIR | Copy every source into DIR. Useful when the directory is not the last argument. |
-T, --no-target-directory | Treat dest as a plain file even if it is a directory. |
--parents | Recreate the source's leading directories under the destination. |
-x, --one-file-system | Do not cross into a different file system while recursing. |
--strip-trailing-slashes | Strip any trailing slashes from each source argument before using it. |
-v, --verbose | Print each file as it is copied. |
--debug | Explain in detail how each file was copied. Implies -v. |
--progress | Show a progress bar. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was copied successfully. |
1 | A file could not be copied — including a requested --preserve that could not be honoured. |
mv
Peios / Using Peios / Peiosutils / Files and directories
mv moves files and directories from one place to another. Moving a file within the same directory, under a new name, is how you rename it.
mv [options] source dest
mv [options] source... directory
mv [options] -t directory source...
$ mv draft.txt report.txt # rename
$ mv report.txt notes.txt /home/jack/done/ # move into a directory
A move is either a rename or a copy-and-delete #
mv does its job in one of two ways, and which one decides what happens to the file's security.
Within one file system, a move is a rename. Nothing is copied: the file stays exactly where it physically is, and only its name and directory entry change. The file keeps its inode, and so it keeps its security descriptor unchanged — every owner, access rule, audit rule, and timestamp is exactly as before. A rename does not re-evaluate inheritance; a file does not pick up new rules just because it landed in a new directory.
Across file systems, a move is a copy followed by a delete. When the destination is on a different file system, mv cannot just rename — it copies the data to a new file on the destination, then removes the original. That new file is a genuinely new object, so, exactly as with cp, it gets a fresh security descriptor inherited from the destination directory unless you ask otherwise.
So: rename a file and its security is untouched; move it to another file system and the copy starts fresh.
Preserving security on a cross-file-system move #
For the copy-and-delete case, mv carries the same --preserve family that cp does — an identical set of options, behaving the same way. They let a cross-file-system move carry the source's owner, access rules, timestamps, and other attributes onto the new file instead of taking the destination directory's inherited default.
The full --preserve reference — the attribute list, --sd, --sd-explicit, --preserve-all, --no-preserve, and what happens when a preserve cannot be honoured — is on the cp page. Everything there applies to mv unchanged.
On a same-file-system move the --preserve options have nothing to do: a rename already keeps every attribute, because it is the same file.
Overwriting an existing destination #
By default mv overwrites an existing destination. These options change that. When more than one of -i, -f, -n is given, the last one wins.
| Option | Effect |
|---|---|
-i, --interactive | Ask before overwriting an existing file. |
-f, --force | Do not prompt before overwriting. |
-n, --no-clobber | Never overwrite an existing file. |
-u, --update[=WHICH] | Overwrite only when the source is newer. WHICH can be all, none, or older. |
-b, --backup[=CONTROL] | Make a backup of the destination before overwriting it. |
Other options #
| Option | Effect |
|---|---|
-t, --target-directory=DIR | Move every source into DIR. |
-T, --no-target-directory | Treat dest as a plain file even if it is a directory. |
--strip-trailing-slashes | Strip any trailing slashes from each source argument. |
-v, --verbose | Print each file as it is moved. |
--debug | Explain in detail how each file was moved. Implies -v. |
--progress | Show a progress bar. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was moved successfully. |
1 | A file could not be moved. |
rm
Peios / Using Peios / Peiosutils / Files and directories
rm removes files. With -r, it removes directories and everything inside them.
rm [options] file...
$ rm draft.txt old-notes.txt
$ rm -r build/
Removal is not reversible — once rm removes a file, its directory entry is gone. rm has a few safety behaviours, below, but the basic rule stands: think before you confirm.
Removing directories #
rm will not remove a directory unless you say so:
| Option | Effect |
|---|---|
-r, -R, --recursive | Remove directories and all of their contents, recursively. |
-d, --dir | Remove a directory, but only if it is already empty. |
The write-protected prompt #
By default — when you have not passed -f and rm is running interactively — rm prompts before removing a file you cannot write to:
$ rm policy.db
rm: remove write-protected regular file 'policy.db'?
The point of that prompt is to catch mistakes: a file you cannot write is one you most likely did not mean to delete.
"Write-protected" here means a real access check. rm decides it by asking the kernel whether your token may write the file — a live check against the file's security descriptor. It is not read off any decorative metadata on the inode; it is the same authority question that any write to the file would face. So the prompt is honest: if rm says a file is write-protected, it is a file the access model would genuinely stop you writing.
(The check is about write access to the file's contents. Whether you may actually remove the file is a separate question, decided by its own access right — so you can be prompted about a write-protected file and still be allowed to delete it. The prompt is a courtesy check, not the authorisation.)
Controlling the prompts #
| Option | Effect |
|---|---|
-f, --force | Never prompt. Also ignore files that do not exist instead of reporting them. |
-i | Prompt before every removal. |
-I | Prompt just once before removing more than three files or before a recursive removal. Less intrusive than -i, but still a check against a slip. |
--interactive[=WHEN] | Set the prompting explicitly: never, once, or always. |
The root failsafe #
rm refuses to recursively remove / — the root of the file system — because doing so by accident would be catastrophic.
| Option | Effect |
|---|---|
--preserve-root | Refuse to recurse on /. This is the default; you do not need to ask for it. |
--no-preserve-root | Disable the failsafe. Required, in full and unabbreviated, if you genuinely intend a recursive operation on /. |
Other options #
| Option | Effect |
|---|---|
-v, --verbose | Print each file as it is removed. |
--progress | Show a progress bar. |
rm and shred #
rm unlinks a file — it removes the name, and the space becomes free. The file's data may still be physically present on the device until something else overwrites it. When you need the contents to be genuinely unrecoverable, use shred, which overwrites the data before removing the file.
Exit status #
| Code | Meaning |
|---|---|
0 | Every requested removal succeeded (or, with -f, the file did not exist). |
1 | A file could not be removed. |
rmdir
Peios / Using Peios / Peiosutils / Files and directories
rmdir removes directories — but only empty ones. If a directory still has anything in it, rmdir leaves it alone and reports the failure.
rmdir [options] directory...
$ rmdir old-cache/
That refusal to touch a non-empty directory is the whole point of rmdir: it is the safe way to remove a directory you believe is empty. If it is not empty, you find out instead of losing the contents. To remove a directory together with everything inside it, use rm -r.
Options #
| Option | Effect |
|---|---|
-p, --parents | Remove the directory and then its ancestors, as long as each becomes empty in turn. rmdir -p a/b/c removes a/b/c, then a/b, then a. |
--ignore-fail-on-non-empty | Do not treat "directory not empty" as a failure. Other failures are still reported. Useful when clearing out whatever directories happen to be empty and leaving the rest. |
-v, --verbose | Print a line for each directory as it is removed. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every directory was removed. |
1 | A directory could not be removed — it was not empty, did not exist, or removal was refused. |
mkdir
Peios / Using Peios / Peiosutils / Files and directories
mkdir creates directories.
mkdir [options] directory...
$ mkdir reports
$ mkdir -p projects/2026/q2
Creating parent directories #
By default mkdir creates exactly the directories you name, and fails if a parent is missing. -p removes that restriction:
| Option | Effect |
|---|---|
-p, --parents | Create any missing parent directories along the way. Also makes mkdir succeed quietly if the directory already exists. |
-v, --verbose | Print a line for each directory as it is created. |
The creation flags #
A new directory needs a security descriptor — the record of who owns it and who may do what to it. By default, a new directory inherits its descriptor from the directory it is created in: it picks up the access rules its parent hands down. For most directories that is exactly right, and you pass no extra options at all.
When you need the new directory to have a different descriptor, the creation flags set it explicitly. These five flags are shared, unchanged, by mkdir, mkfifo, mknod, and touch — whenever one of those commands creates a new object, it accepts this same set. This page is the full reference for them.
| Flag | Argument | Effect |
|---|---|---|
--owner | SID | Make this principal the owner, instead of you. |
--group | SID | Set the object's group. |
--label | level | Give the object a mandatory integrity label. |
--no-inherit | — | Do not inherit rules from the parent directory; lock the object to its owner. |
--sddl | SDDL string | Supply the entire security descriptor directly. |
A SID argument is either a literal S-1-… value or a well-known alias — BA for the built-in administrators, for example.
--label takes one of these levels, lowest to highest: untrusted, low, medium, medium-plus, high, system, protected. The label applies the standard rule for files and directories — a principal below the object's level cannot write to it.
Inherited, or explicit #
The default and --no-inherit are the two ends of a choice.
- Inherited (the default) — the new directory tracks its parent. Rules the parent hands down apply to it, and as the parent's rules change the directory follows along. This keeps a tree of directories consistent without per-directory effort.
--no-inherit— the new directory takes nothing from its parent. Its access is locked to its owner, and it does not follow the parent's rules. Use it to carve out a directory whose security stands apart from the tree around it.
See Inheritance for the full model.
--sddl for a complete descriptor #
The four flags above are shortcuts for common adjustments. When you need to specify the whole descriptor — a particular set of access rules, audit rules, owner, and group all at once — --sddl takes it as a single string in the security-descriptor definition language:
$ mkdir --sddl 'O:BAG:BAD:(A;OICI;FA;;;BA)' secure-area
--sddl is mutually exclusive with --owner, --group, --label, and --no-inherit — it already says everything those flags would say, so combining them is rejected.
How the descriptor is applied #
mkdir creates the directory first and then applies the descriptor to it. The two steps are not separately visible — mkdir either finishes with the directory created and secured as asked, or fails as a whole. If applying the descriptor fails, the half-created directory is not left behind.
Exit status #
| Code | Meaning |
|---|---|
0 | Every directory was created. |
1 | A directory could not be created, or a creation flag could not be applied. |
mkfifo
Peios / Using Peios / Peiosutils / Files and directories
mkfifo creates named pipes, also called FIFOs.
mkfifo [options] name...
$ mkfifo events
A named pipe is a file that two programs use to pass a stream of data: one writes into it, the other reads from it, and the data flows through in order — first in, first out, which is what "FIFO" stands for. Unlike an ordinary pipe between two commands, a named pipe has a name on the file system, so the two programs do not have to be started together or be related to each other.
Setting the new pipe's security #
A FIFO is a file, and a new file needs a security descriptor. By default a new FIFO inherits its descriptor from the directory it is created in.
mkfifo accepts the shared creation flags — --owner, --group, --label, --no-inherit, and --sddl — to set that descriptor explicitly instead. They behave exactly as they do for mkdir; the full reference is on the mkdir page.
Exit status #
| Code | Meaning |
|---|---|
0 | Every FIFO was created. |
1 | A FIFO could not be created — for example, the name already exists. |
mknod
Peios / Using Peios / Peiosutils / Files and directories
mknod creates special files — files that are not data but an interface to something else: a device, or a named pipe.
mknod [options] name type [major minor]
$ mknod /dev/mydisk b 8 0
$ mknod backlog p
The type argument #
The type says what kind of special file to create:
| Type | Creates |
|---|---|
b | A block device — a device addressed in fixed-size blocks, such as a disk. |
c or u | A character device — a device addressed as a stream of bytes, such as a terminal. |
p | A named pipe (FIFO). |
For a block or character device (b, c, u) you must also give a major and a minor number. The major number selects which driver handles the device; the minor number tells that driver which specific device is meant. For a pipe (p), the major and minor numbers are omitted — a pipe has no driver behind it.
A major or minor number is read as hexadecimal if it begins with 0x, as octal if it begins with 0, and as decimal otherwise.
mknod NAME p and mkfifo NAME do the same thing; mkfifo exists as the clearer way to ask for just a pipe.
Setting the new file's security #
A special file needs a security descriptor like any other file, and by default a new one inherits its descriptor from the directory it is created in.
mknod accepts the shared creation flags — --owner, --group, --label, --no-inherit, and --sddl — to set that descriptor explicitly. They behave exactly as they do for mkdir; the full reference is on the mkdir page.
Exit status #
| Code | Meaning |
|---|---|
0 | The special file was created. |
1 | It could not be created — a missing or invalid type, missing device numbers, or a name that already exists. |
touch
Peios / Using Peios / Peiosutils / Files and directories
touch does one of two things, depending on whether the file already exists:
- if the file does not exist,
touchcreates it, empty; - if the file does exist,
touchupdates its timestamps to the current time.
touch [options] [file...]
$ touch notes.txt # create notes.txt if absent, else bump its times
The "create an empty file" behaviour is the everyday use. The "update timestamps" behaviour matters to tools that decide what work to do by comparing file times — touch is how you tell such a tool that a file should be considered freshly changed.
Which timestamps, and to what #
By default touch sets both the access time and the modification time to the current moment. These options narrow or redirect that.
| Option | Effect |
|---|---|
-a | Change only the access time. |
-m | Change only the modification time. |
--time=WORD | Choose which time to change by name: access, atime, or use for the access time; modify or mtime for the modification time. |
-d, --date=STRING | Use the time described by STRING instead of the current time. |
-t STAMP | Use the time given as [[CC]YY]MMDDhhmm[.ss] instead of the current time. |
-r, --reference=FILE | Use the timestamps of FILE instead of the current time. |
-h, --no-dereference | If the named file is a symbolic link, change the link's own timestamps rather than those of the file it points to. |
Not creating a file #
To use touch purely to update timestamps, and never to create anything:
| Option | Effect |
|---|---|
-c, --no-create | Do not create a file that does not exist. A named file that is absent is silently skipped. |
Setting a created file's security #
When touch creates a file, that new file needs a security descriptor, and by default it inherits one from the directory it is created in.
touch accepts the shared creation flags — --owner, --group, --label, --no-inherit, and --sddl — to set that descriptor explicitly. They behave exactly as they do for mkdir; the full reference is on the mkdir page.
These flags apply only on the create path. If touch is updating the timestamps of a file that already exists, there is no new descriptor to set, and the creation flags have nothing to do — they do not alter an existing file's security. Pair them with -c and they never take effect at all, since -c means nothing is ever created.
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was created or updated as requested. |
1 | A file could not be created or updated. |
ln
Peios / Using Peios / Peiosutils / Files and directories
ln creates links — extra ways to reach a file.
ln [options] target link_name
ln [options] target
ln [options] target... directory
ln [options] -t directory target...
$ ln -s /opt/app/bin/app /lcl/bin/app # a symbolic link
$ ln data.csv data-archive.csv # a hard link
Hard links and symbolic links #
There are two kinds of link, and they are genuinely different things.
A hard link is another name for the same file. The file's data exists once; a hard link is an additional directory entry pointing at it. Every hard link to a file is equal — none is "the original" — and the file's data stays as long as at least one hard link remains. Hard links cannot span file systems, and cannot be made to directories.
A symbolic link is a small file that simply contains a path. When something opens a symbolic link, it is redirected to whatever that path names. The symbolic link and its target are separate files: if the target is moved or removed, the link still exists but now points at nothing — it "dangles". Symbolic links can point anywhere, across file systems and at directories.
ln creates hard links by default, and symbolic links with -s. When in doubt, -s — a symbolic link's behaviour is the easier one to reason about.
The two also differ in how they are secured. A hard link is not a new file, so creating one involves no new security descriptor — every name for the file shares the file's existing one. A symbolic link is a new file, and like any new file it inherits its own descriptor from the directory it is created in.
The forms #
ln target link_name— create one link namedlink_name.ln target— create a link totargetin the current directory, with the same final name.ln target... directory— create a link to eachtargetinsidedirectory.ln -t directory target...— the same, with the directory named first.
Options #
| Option | Effect |
|---|---|
-s, --symbolic | Make symbolic links instead of hard links. |
-f, --force | Remove an existing destination file so the link can be created. |
-i, --interactive | Ask before removing an existing destination. |
-r, --relative | For a symbolic link, write the target as a path relative to the link's own location, rather than an absolute path. |
-n, --no-dereference | If link_name is itself a symbolic link to a directory, treat it as an ordinary file — replace the link rather than create something inside the directory it points to. |
-L, --logical | When the target is a symbolic link, link to what it points to. |
-P, --physical | Make a hard link to a symbolic link itself, rather than to its target. |
-t, --target-directory=DIR | Create all links inside DIR. |
-T, --no-target-directory | Treat link_name as a plain file, never as a directory to create links inside. |
-b, --backup[=CONTROL] | Back up an existing destination before replacing it. |
-S, --suffix=SUFFIX | Use SUFFIX for backup file names. |
-v, --verbose | Print the name of each link as it is created. |
Inspecting links afterwards #
readlink prints where a symbolic link points. The low-level link command makes a single hard link with no options, for scripts that want exactly that and nothing else.
Exit status #
| Code | Meaning |
|---|---|
0 | Every link was created. |
1 | A link could not be created. |
link and unlink
Peios / Using Peios / Peiosutils / Files and directories
link and unlink are the bare, single-purpose versions of two operations that ln and rm also perform. Each does exactly one thing, takes exactly its operands, and has no options. They exist for scripts that want one precise operation with no surrounding behaviour — no prompting, no link kinds to choose, no recursion.
link #
link file1 file2
link creates one hard link: a new name, file2, for the existing file file1. file1 must exist; file2 must not.
$ link data.csv data-archive.csv
That is the whole command. It performs the link operation directly and reports whether it succeeded. For anything more — symbolic links, replacing an existing destination, linking into a directory — use ln.
unlink #
unlink file
unlink removes one file: it deletes the directory entry file. It takes a single name and removes it directly.
$ unlink stale.lock
unlink does not prompt, does not recurse, and does not remove directories. For removing several files, removing directories, or any of the safety prompts, use rm.
Why they exist #
ln and rm are the commands to use day to day — they are flexible and have the safety behaviours. link and unlink are deliberately rigid: a script that calls unlink will only ever remove a single file, and a reviewer can see that at a glance. The narrowness is the feature.
Exit status #
Both commands:
| Code | Meaning |
|---|---|
0 | The operation succeeded. |
1 | The operation failed — a missing or already-existing operand, or a refused request. |
shred
Peios / Using Peios / Peiosutils / Files and directories
shred destroys a file's contents. It does not just unlink the file — it overwrites the data, repeatedly, so that what was there cannot be read back even with effort and specialised equipment.
shred [options] file...
$ shred -u -v secret-keys.txt
rm removes a file's name; the data may sit on the device, recoverable, until something else happens to overwrite it. shred overwrites the data on purpose. Use it when a file held something that must not be recoverable.
By default shred overwrites a file three times and then leaves the file in place — empty of its old contents but still present. That default exists because shred is often pointed at device files, which usually should not be removed. To remove the file after shredding it, pass -u.
When shredding actually works #
shred relies on one assumption: that writing to the file overwrites the same physical storage the old data occupied. On a traditional file system that holds. Several common file system designs break it — anything that writes new data to a fresh location instead of in place, keeps snapshots, journals file data, caches copies elsewhere, or compresses. On those, shred cannot guarantee the old data is gone.
Backups and remote mirrors are a separate matter entirely: shred cannot reach a copy of the file that lives somewhere else.
Treat shred as effective on a plain local file system and uncertain otherwise.
Options #
| Option | Effect |
|---|---|
-n, --iterations=N | Overwrite N times instead of the default of 3. |
-u, --remove[=HOW] | Remove the file after overwriting it. HOW controls how thoroughly the name itself is obscured before the file is unlinked. |
-z, --zero | Add a final pass that writes zeros, so the file does not visibly look shredded afterwards. |
-s, --size=N | Shred N bytes, rather than the whole file. Accepts size suffixes such as K, M, G. |
-x, --exact | Do not round the shredded size up to the next full block. Without it, shred rounds up so the block's slack space is overwritten too. |
--random-source=FILE | Take the random bytes for the overwrite passes from FILE. |
-v, --verbose | Show progress as each pass runs. |
-f, --force | Override a file's protection: when a live access check shows you cannot write the file, rewrite its security descriptor so the overwrite can proceed (see below). |
--force and a file's protection #
To overwrite a file, shred has to be able to write it. If the file's security descriptor does not grant you write access, an ordinary shred cannot proceed.
-f (--force) handles that case. When --force is given and a live access check shows you cannot currently write the file, shred rewrites the file's security descriptor to one that grants full access, and then overwrites the file. The reasoning is deliberate: the file is about to be destroyed anyway, and --force is you saying "override this file's protection."
--force can do this only when you are allowed to change the file's security in the first place — that is, when you own the file or hold the right to rewrite its access rules. If you cannot change the descriptor, --force cannot grant itself write access, and the shred still fails. --force overrides a file's protection; it does not manufacture authority you do not have.
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was shredded successfully. |
1 | A file could not be shredded. |
dd
Peios / Using Peios / Peiosutils / Files and directories
dd copies data from one place to another, a block at a time, and can transform the data as it goes. It is the tool for low-level copying — writing a disk image to a device, extracting an exact byte range out of a file, copying from or to a raw device.
dd [operand]...
$ dd if=disk.img of=/dev/mydisk bs=4M status=progress
How dd is told what to do #
dd does not take options in the usual -x form. It takes operands, each written name=value, in any order. With no if= operand it reads from standard input; with no of= operand it writes to standard output.
The operands #
| Operand | Meaning |
|---|---|
if=FILE | Read input from FILE. |
of=FILE | Write output to FILE. |
bs=BYTES | Read and write BYTES at a time. Sets both the input and output block size at once. |
ibs=BYTES | Input block size (default 512). |
obs=BYTES | Output block size (default 512). |
cbs=BYTES | Conversion block size — used by the block and unblock conversions. |
count=N | Stop after N input blocks, rather than reading to the end. |
skip=N | Skip N input blocks before copying. Also spelled iseek=N. |
seek=N | Skip N output blocks before writing. Also spelled oseek=N. |
conv=LIST | Apply the comma-separated conversions below. |
iflag=LIST | Comma-separated input flags — how the input is opened and read. |
oflag=LIST | Comma-separated output flags — how the output is opened and written. |
status=LEVEL | How much to report: progress (periodic stats while copying), noxfer (final counts only), none (silent). |
A size value accepts a unit suffix (K, M, G, …). So bs=4M is four mebibytes per block.
Conversions (conv=) #
| Value | Effect |
|---|---|
ucase / lcase | Convert the data to upper-case / lower-case. |
swab | Swap every adjacent pair of bytes. |
sync | Pad each input block out to its full size — with zeros, or with spaces alongside block/unblock. |
block / unblock | Convert between newline-terminated lines and fixed-size cbs records. |
sparse | Where an output block is all zeros, seek past it instead of writing it. |
excl | Fail if the output file already exists. |
nocreat | Fail if the output file does not already exist. |
notrunc | Do not truncate the output file when opening it. |
noerror | Continue past read errors instead of stopping. |
fdatasync / fsync | Flush the data — or the data and metadata — to storage before finishing. |
When dd creates the output file — an of= file that does not already exist — the new file needs a security descriptor, and it inherits one from the directory it is created in.
Input and output flags (iflag=, oflag=) #
| Flag | Applies to | Effect |
|---|---|---|
count_bytes | input | Interpret count=N as a number of bytes, not blocks. |
skip_bytes | input | Interpret skip=N as a number of bytes. |
fullblock | input | Wait for a full ibs of data on each read. |
seek_bytes | output | Interpret seek=N as a number of bytes. |
append | output | Open the output in append mode. |
direct | both | Use direct I/O, bypassing the cache. |
dsync / sync | both | Use synchronised I/O — for data, or for data and metadata. |
nonblock | both | Use non-blocking I/O. |
noatime | both | Do not update the file's access time. |
nocache | both | Ask the system to drop the file's cached pages. |
directory | both | Fail unless the file is a directory. |
What dd prints #
Unless status=none is set, dd prints a summary when it finishes:
16+0 records in
16+0 records out
67108864 bytes (67 MB, 64 MiB) copied, 1.234 s, 54.4 MB/s
The records in / records out counts are written complete+partial — full-sized blocks plus any short final block. status=progress prints the last line periodically during a long copy.
Exit status #
| Code | Meaning |
|---|---|
0 | The copy completed. |
1 | The copy failed — a bad operand, an I/O error, or a file that could not be opened. |
df
Peios / Using Peios / Peiosutils / Files and directories
df — "disk free" — reports, for each file system, how much space it has and how much is still available.
df [options] [file...]
With no arguments, df reports on every mounted file system. Given a file, it reports on the one file system that file lives on.
$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/sda2 456G 198G 235G 46% /
/dev/sda1 511M 12M 499M 3% /boot
The default columns #
Each line is one file system:
- Filesystem — the device or source backing it.
- Size — its total capacity.
- Used — space in use.
- Avail — space still available.
- Use% — used space as a percentage.
- Mounted on — where it is attached in the directory tree.
Choosing the units #
By default sizes are shown in blocks. These options change that.
| Option | Effect |
|---|---|
-h, --human-readable | Show sizes with unit suffixes — 456G, 12M — using powers of 1024. |
-H, --si | Likewise, but using powers of 1000. |
-B, --block-size=SIZE | Show sizes scaled to SIZE — -BM reports in megabytes. |
-k | Use 1024-byte blocks. |
Choosing what to report #
| Option | Effect |
|---|---|
-a, --all | Include dummy and otherwise-hidden file systems that df would normally skip. |
-l, --local | Report only local file systems, skipping network-mounted ones. |
-t, --type=TYPE | Report only file systems of type TYPE. |
-x, --exclude-type=TYPE | Report everything except file systems of type TYPE. |
--total | Add a final line giving the grand total across everything reported. |
Changing the output #
| Option | Effect |
|---|---|
-i, --inodes | Report inode counts — total, used, and free — instead of block space. A file system can run out of inodes while it still has space. |
-T, --print-type | Add a column showing each file system's type. |
--output[=LIST] | Replace the default columns with exactly the fields named in LIST. With no list, print every available field. |
-P, --portability | Use a fixed, simple column layout that does not wrap a long device name onto its own line. |
Sync behaviour #
| Option | Effect |
|---|---|
--sync | Flush pending writes before reading the usage figures, for the most up-to-date numbers. |
--no-sync | Do not flush first. This is the default and is faster. |
Exit status #
| Code | Meaning |
|---|---|
0 | The report was produced. |
1 | A failure — for example, a named file does not exist, or the table of file systems could not be read. |
du
Peios / Using Peios / Peiosutils / Files and directories
du — "disk usage" — reports how much space files and directories occupy. Where df tells you about a whole file system, du tells you where the space has actually gone.
du [options] [file...]
$ du -sh /home/jack/projects
4.2G /home/jack/projects
With no arguments, du works on the current directory. By default it walks the directory tree, prints the cumulative size of each directory (a directory's size includes everything under it), and ends with the total for the argument itself. Individual files are not listed unless you ask for them.
What to count and how deep #
| Option | Effect |
|---|---|
-a, --all | List every file, not only directories. |
-s, --summarize | Print only a single total for each argument — no per-subdirectory breakdown. |
-d, --max-depth=N | Show totals only down to N levels below each argument. -d 0 is the same as -s. |
-S, --separate-dirs | Give each directory's own size, excluding its subdirectories. |
-c, --total | Add a final grand-total line. |
--inodes | Count inodes used rather than space used. |
What "size" means #
| Option | Effect |
|---|---|
-h, --human-readable | Sizes with unit suffixes — 4.2G — using powers of 1024. |
--si | Likewise, using powers of 1000. |
-B, --block-size=SIZE | Scale sizes to SIZE. |
-k / -m | Use 1024-byte / 1024×1024-byte units. |
--apparent-size | Report the apparent size of files — how much data they contain — rather than the disk space they occupy. The two differ for sparse files and because of block rounding. |
-b, --bytes | Report plain byte counts. Equivalent to --apparent-size --block-size=1. |
Following symbolic links #
By default du does not follow symbolic links.
| Option | Effect |
|---|---|
-L, --dereference | Follow all symbolic links and count what they point to. |
-D, -H, --dereference-args | Follow only the symbolic links named directly on the command line. |
-P, --no-dereference | Follow no symbolic links. This is the default. |
-x, --one-file-system | Do not cross into a different file system while walking. |
-l, --count-links | Count a hard-linked file every time it is encountered, instead of once. |
Limiting the output #
| Option | Effect |
|---|---|
--exclude=PATTERN | Skip files and directories whose name matches PATTERN. |
--exclude-from=FILE | Skip everything matching any pattern listed in FILE. |
-t, --threshold=SIZE | Show only entries at least SIZE (or, with a negative SIZE, at most that big). |
--files0-from=F | Take the list of files to measure from F, NUL-separated. - means standard input. |
--time[=WORD] | Also show a timestamp — by default the latest modification time anywhere in the directory. |
-0, --null | End each output line with a NUL character instead of a newline. |
-v, --verbose | Report extra detail about what is being processed. |
Exit status #
| Code | Meaning |
|---|---|
0 | The report was produced. |
1 | A file or directory could not be accessed. |
truncate
Peios / Using Peios / Peiosutils / Files and directories
truncate sets a file's size to an exact figure — shrinking it, or extending it.
truncate [options] file...
$ truncate -s 0 logfile # empty the file
$ truncate -s 1G disk.img # make a 1 GiB file
Shrinking a file discards everything past the new size. Extending it adds space that reads back as zero bytes; that added space is a hole — it costs no actual storage until something writes real data into it, which is how truncate -s 1G can create a "1 GiB file" instantly.
By default, truncate creates a file that does not yet exist (as an empty file, then sized as asked). A file created this way needs a security descriptor, and it inherits one from the directory it is created in.
Specifying the size #
-s (--size) takes the size. A plain number sets the size outright. A unit suffix scales it: K, M, G, T, … where the bare letter is a power of 1024 (K = 1024) and the letter with B is a power of 1000 (KB = 1000).
A size may also begin with a prefix, which makes it relative to the file's current size:
| Prefix | Meaning |
|---|---|
+ | Extend by this much. |
- | Reduce by this much. |
< | Shrink to this size only if the file is currently larger ("at most"). |
> | Extend to this size only if the file is currently smaller ("at least"). |
/ | Round the size down to a multiple of this number. |
% | Round the size up to a multiple of this number. |
$ truncate -s +4K notes.txt # 4 KiB larger than it is now
$ truncate -s '<1M' notes.txt # cap it at 1 MiB, leave smaller files alone
Options #
| Option | Effect |
|---|---|
-s, --size=SIZE | Set or adjust the size to SIZE, as described above. |
-r, --reference=RFILE | Base the size on the current size of RFILE instead of giving a number. Combine with a relative -s to mean "the size of RFILE, adjusted". |
-c, --no-create | Do not create a file that does not exist; skip it instead. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was resized. |
1 | A file could not be resized — a bad size, or a file that could not be opened. |
mktemp
Peios / Using Peios / Peiosutils / Files and directories
mktemp creates a temporary file — or, with -d, a temporary directory — gives it a name that is not already taken, and prints that name.
mktemp [options] [template]
$ mktemp
/tmp/tmp.QV8x3kZ1aR
$ mktemp -d
/tmp/tmp.7bNc0wPe2L
The reason to use mktemp rather than inventing a name yourself is safety. A script that picks a fixed name like /tmp/work has two problems: two copies of the script collide, and a name an attacker can predict is a name an attacker can interfere with before the script gets to it. mktemp chooses an unpredictable name and creates the file in the same step, so nothing can slip in between. It prints the name it used, for the script to capture:
work=$(mktemp)
# ...use "$work"...
rm "$work"
The temporary file or directory mktemp creates is a new object, and a new object needs a security descriptor; it inherits one from the directory it is created in.
Templates #
The name comes from a template — a name with a run of trailing X characters, each of which mktemp replaces with a random character. More Xs means a wider range of possible names.
$ mktemp report-XXXXXX.txt
report-a8Kp2Q.txt
With no template, mktemp uses a built-in default that places the file in the temporary directory. A template needs at least three Xs.
Options #
| Option | Effect |
|---|---|
-d, --directory | Create a directory instead of a file. |
-p, --tmpdir[=DIR] | Create the temporary object inside DIR. With no DIR, use the directory named by the TMPDIR environment variable, or the system temporary directory. The template is then just the final name component. |
--suffix=SUFFIX | Append SUFFIX after the random part of the name — for giving the file an extension. |
-u, --dry-run | Do not create anything; just print a name that would be free. This reintroduces the race the command exists to avoid — use it only when you genuinely cannot create the object yet. |
-q, --quiet | Print no error message if creation fails; rely on the exit status. |
Exit status #
| Code | Meaning |
|---|---|
0 | The temporary file or directory was created; its name was printed. |
1 | Creation failed — a bad template, or a directory that does not exist. |
sd
Peios / Using Peios / Peiosutils / Files and directories
sd is the command-line tool for working with security descriptors on files. Everything this topic describes — owners, DACLs, ACEs, the SACL, integrity labels, inheritance — sd is how you read it and change it from a shell.
sd subcommand path [arguments]
$ sd show ./report.txt
$ sd allow ./report.txt alice:read
$ sd owner ./report.txt BA
Where ls -l shows a file's owner and a summary, and cp --preserve carries a descriptor across, sd is the tool that edits the descriptor directly.
The subcommands #
| Group | Subcommand | Does |
|---|---|---|
| Inspect | show | Print the descriptor on a path. |
check | Simulate an access check against the path. | |
| DACL | allow | Add an allow rule for one or more principals. |
deny | Add a deny rule for one or more principals. | |
remove | Drop every DACL rule for the named principals. | |
| Auditing | audit | Add an audit rule to the SACL. |
unaudit | Drop every SACL rule for the named principals. | |
| Ownership | owner | Set the descriptor's owner. |
group | Set the descriptor's group. | |
| Integrity | integrity | Set the mandatory integrity label. |
| Inheritance | inherit | Turn inheritance protection on or off. |
reset | Drop the file's own rules and re-inherit from the parent. | |
propagate | Push inheritance down to descendants. | |
| Wholesale | set | Replace the entire descriptor at once. |
Naming a principal #
Wherever a subcommand takes a PRINCIPAL, it accepts any of:
| Form | Example | Meaning |
|---|---|---|
@self | @self | The user SID of the token running sd. |
@owner | @owner | A placeholder that the access check substitutes with the file's own owner. |
| A well-known label | Everyone, Administrators, LocalSystem | A named built-in principal. |
| A two-letter alias | WD, BA, SY | The short alias for a well-known principal. |
| A raw SID | S-1-5-32-544 | Any SID, written out in full. |
See SIDs for what these are.
Naming permissions #
Wherever a subcommand takes PERMS, several notations are accepted, and may be mixed:
| Form | Example | Meaning |
|---|---|---|
| Single letters | rwx, r, m | r read, w write, x execute, d delete, m modify, f full, c change-permissions, o take-ownership. |
| Words | read,write, modify | The same set, spelled out. |
| Fine-grained names | read-data,append,traverse | Individual low-level rights, for precise rules. |
| Raw hex | 0x1F01FF | An access mask written directly. |
Run letters together (rwx) or separate names with commas (read,write,execute).
Inspecting #
sd show #
Prints the descriptor on a path — the owner, the group, the DACL, the SACL.
$ sd show ./report.txt
| Flag | Effect |
|---|---|
--sddl | Render the descriptor as an SDDL string. |
--raw | Render SIDs in raw S-1-… form only. |
--label | Render SIDs as their labels where known. |
--all | Verbose — decode every flag and show raw masks alongside. |
--json | Emit JSON. |
sd check #
Simulates an access decision: "would this access be allowed?" — without performing it.
$ sd check ./report.txt write
$ sd check ./report.txt read --pid 4821 --explain
| Argument / flag | Effect |
|---|---|
PERMS | The access to test for. |
--pid PID | Check against process PID's token instead of your own. |
--explain | Show why the decision came out as it did — the rule-by-rule walk. |
sd check is the first thing to reach for when an access is denied and you do not know why. --explain walks the descriptor the same way the kernel does.
Changing the DACL #
The DACL is the list of allow and deny rules. These three subcommands edit it.
sd allow and sd deny #
Add an allow (or deny) rule for one or more PRINCIPAL:PERMS pairs.
$ sd allow ./report.txt alice:read bob:rw
$ sd deny ./report.txt Everyone:write
| Flag | Effect |
|---|---|
--flags LIST | ACE inheritance flags — CI container-inherit, OI object-inherit, NP no-propagate, IO inherit-only; none clears them. |
--if EXPR | Make it a conditional rule, applied only when EXPR is true. |
--replace | Drop any existing rules for this principal and kind first, instead of appending. |
--recursive, -r | Apply to every descendant of the path. |
Remember that a deny rule, when it matches, wins over any allow — see DACL evaluation.
sd remove #
Drops every DACL rule — allow and deny — for the named principals.
$ sd remove ./report.txt bob carol
| Flag | Effect |
|---|---|
--allow-empty | Permit the result to be a present-but-empty DACL, which denies everyone. Without this, sd refuses to produce one. |
--recursive, -r | Apply to every descendant. |
Auditing #
sd audit and sd unaudit #
The SACL holds audit rules — see The SACL. sd audit adds one; sd unaudit drops every SACL rule for the named principals.
$ sd audit ./secrets.db Everyone:write:failure
An audit spec is PRINCIPAL:PERMS:WHEN, where WHEN is success, failure, or both — which outcomes to log. sd audit takes the same --flags, --if, --replace, and -r options as sd allow.
Ownership #
sd owner and sd group #
Set the descriptor's owner or group SID.
$ sd owner ./report.txt alice
$ sd group ./report.txt Administrators
Both take a single PRINCIPAL and accept -r. Changing an owner is itself an access-controlled act — see Ownership.
Integrity #
sd integrity #
Sets the file's mandatory integrity label.
$ sd integrity ./report.txt high
The level is one of untrusted, low, medium, medium-plus, high, system, protected. The standard catalog is five levels (untrusted, low, medium, high, system); medium-plus (RID 8448) and protected (RID 20480) are non-standard Windows-compatibility levels — the kernel compares any S-1-16-<rid> numerically.
| Flag | Effect |
|---|---|
--policy BITS | The label's policy bits, comma-separated: NW no write-up, NR no read-up, NX no execute-up. |
--recursive, -r | Apply to every descendant. |
For what the label does, see Mandatory integrity control.
Inheritance #
sd inherit #
Turns inheritance protection on or off — the + mark ls -l shows.
$ sd inherit off ./report.txt # lock the file; stop inheriting
$ sd inherit on ./report.txt # let it inherit from its parent again
inherit on lets the file inherit rules from its parent directory. inherit off protects the file — it keeps its current rules and stops tracking the parent.
| Flag | Effect |
|---|---|
--strip-inherited | When turning protection off, also drop the inherited rules already on the file. |
--recursive, -r | Apply to every descendant. |
sd reset #
Drops the file's own explicit rules and rebuilds its DACL purely from what the parent directory hands down — returning the file to "inherits everything".
sd propagate #
Pushes this directory's inheritable rules down into its descendants, refreshing what they inherit. Use it after changing a directory's rules so the children pick up the change.
See Inheritance for the full model.
Replacing the whole descriptor #
sd set #
Replaces the entire descriptor in one step.
$ sd set ./report.txt 'O:BAG:BAD:P(A;;FA;;;BA)(A;;0x1200a9;;;BU)'
| Argument / flag | Effect |
|---|---|
SDDL | The new descriptor as an SDDL string. - reads it from standard input. |
--binary FILE | Instead of SDDL, read the raw descriptor bytes from FILE (- for standard input). |
--components LIST | Override which parts of the descriptor (owner, group, DACL, SACL) the operation writes. |
Common flags #
These apply across the subcommands:
| Flag | Effect |
|---|---|
--recursive, -r | Apply the change to every descendant of the path. |
--no-follow-symlinks, -P | Operate on a symbolic link itself, not the file it points to. |
--json | Emit JSON instead of human-readable output. |
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — or, for sd check, the access would be allowed. |
| non-zero | The operation failed, the path was unreachable, or — for sd check — the access would be denied. |
Listing and paths
Peios / Using Peios / Peiosutils / Listing and paths
Before you can do anything with a file you usually have to find it — see what a directory holds, learn one file's details, turn a relative name into an absolute one. This topic covers the commands for that: listing directories, inspecting a single file, and resolving path names.
This page names every command in the topic, says in one line what each is for, and points you at the page that covers it in full.
The commands #
| Command | Purpose |
|---|---|
ls | List the contents of a directory. The everyday command for "what's in here?". |
dir and vdir | ls with a fixed output style. dir lists in columns; vdir lists in long format. |
stat | Show the detailed status of one file — its type, size, timestamps, and raw inode metadata. |
pwd | Print the directory you are currently working in. |
readlink | Print where a symbolic link points. |
realpath | Resolve a path to its canonical, absolute form — every symlink followed, every . and .. removed. |
basename | Strip the directory part off a path, leaving just the final name. |
dirname | The opposite of basename — strip the final name, leaving the directory. |
pathchk | Check whether a path name is valid and portable before you try to create it. |
dircolors | Produce the colour settings that ls uses when it colours its output. |
These commands and file security #
Most commands in this topic work purely on path strings — they never touch the file the path names. basename, dirname, realpath, and pathchk are all string operations.
Two report on real files, and they relate to file security differently:
ls -lsurfaces security-descriptor information directly. On Peios a file's access is governed by its security descriptor — the record of who owns the file and who may do what to it. The long listing shows the owner and the parts of the descriptor that fit in a one-line-per-file format.statis a low-level diagnostic. It dumps the raw contents of a file's inode. The inode carries some numeric fields — an owner id, a group id, a mode value — that the Peios access model does not consult.statreports them because it reports the inode verbatim, but they are not the file's access policy.
Neither command prints the full security descriptor; inspecting that is a job for the dedicated security tooling. If a line of ls -l looks unfamiliar, or you want to know why stat shows fields that "don't count", the explanation is in Security descriptors.
Where to start #
If you want the everyday "what's in this directory" command and the meaning of every column it prints, read ls.
If you need the full detail of one specific file, read stat.
If you are writing a script and need to take a path apart or canonicalise it, the string commands — basename, dirname, realpath — are what you want.
ls
Peios / Using Peios / Peiosutils / Listing and paths
ls lists the contents of a directory. It is the command you use to answer "what is in here?" — and, with the right options, "who owns these files, how big are they, and when were they last changed?".
ls [options] [file...]
Each file is a directory to list the contents of, or a single file to report on. With no file argument, ls lists the directory you are currently in.
$ ls
build.sh notes.txt projects
The default listing #
Run on its own, ls prints the names of the entries in a directory and nothing else. Three rules shape that default:
- Hidden entries are skipped. A name beginning with a dot (
.config,.cache) is hidden.lsleaves hidden entries out unless you ask for them with-aor-A. - Entries are sorted by name. Alphabetical order, case-sensitive. Other sort orders are available.
- The layout adapts to where output goes. When
lsis writing to a terminal it arranges names into columns sized to the terminal width. When output is redirected to a file or a pipe it prints one name per line, so the result is easy for another program to read.
Everything past this default is opt-in. The rest of this page is the options, grouped by what they do.
The long format #
-l (or --long) switches ls into the long format: one entry per line, each with its mode, owner, size, modification time, and name.
$ ls -l
total 28
d-- S-1-5-21-9f3a-1c4e-7b20-1001 4096 May 14 09:12 projects
-x- S-1-5-21-9f3a-1c4e-7b20-1001 8344 May 16 17:40 build.sh
--- S-1-5-21-9f3a-1c4e-7b20-1001 219 May 15 11:03 notes.txt
--+ S-1-5-18 12048 May 10 22:15 policy.db
The total line at the top reports the combined disk space used by the listed files, measured in blocks.
Each entry line has five columns: mode, owner, size, modification time, name. There is no permission-bits column and no group column — the reason is below.
The mode column #
The mode column is three characters: [type][executable][protected].
The first character is the file's type:
| Character | Type |
|---|---|
- | Regular file |
d | Directory |
l | Symbolic link |
p | Named pipe (FIFO) |
s | Socket |
c | Character device |
b | Block device |
The second character is the executable mark. It is x when the entry is a regular file that is marked as an executable, and - otherwise (it is always - for directories, links, and other non-regular entries).
The executable mark describes the file, not your relationship to it. x means "this file is runnable code." It does not mean that you, specifically, are allowed to run it — whether a given principal may execute a file is a separate access decision made against the file's security descriptor. A file can show x and still be one you cannot run, and the reverse. See Access decisions for how execution is actually authorised.
The third character is the protected mark. It is + when the file's access control is inheritance-protected, and - otherwise.
A - here means the file's access control is inherited from the directory it lives in: it tracks its parent. A + means the file's access control has been set deliberately and locked, so it no longer tracks the parent directory. The + is the signal that someone has tailored this file's security on purpose. See Inheritance.
So in the example above: projects is a directory (d--); build.sh is an executable file (-x-); notes.txt is an ordinary file inheriting its security from its directory (---); and policy.db is an ordinary file whose access control has been protected (--+).
Why there are no permission bits #
A long listing does not carry a fixed block of permission characters. On Peios, what a principal may do to a file is decided by the file's security descriptor — a record that can contain any number of entries granting or denying specific rights to specific principals. There is no small, fixed set of bits that captures that, so ls -l does not pretend there is.
The mode column tells you the file's type and two facts about it (is it executable, is its security protected). To see who may do what, look at the security descriptor itself with stat or the dedicated security tooling. The concepts are in Security descriptors.
The owner column #
The owner column is the SID of the file's owner — shown in full, in the S-1-… form. ls does not translate SIDs into account names; it prints the identifier exactly as the security descriptor stores it. A well-known owner such as the system itself appears as its fixed SID (S-1-5-18); an ordinary account appears as a longer domain-style SID.
For what a SID is and how to read one, see SIDs. For what "owner" means and what it grants, see Ownership.
If ls cannot read a file's descriptor — for a dangling symlink, say — the owner column shows ? and the mode column shows the type character followed by ??.
Choosing what to list #
By default ls shows non-hidden entries, and for a directory argument it shows the directory's contents. These options change that.
| Option | Effect |
|---|---|
-a, --all | Show hidden entries too, including . (the directory itself) and .. (its parent). |
-A, --almost-all | Show hidden entries, but leave out . and ... |
-d, --directory | List a directory as an entry in its own right, instead of listing its contents. Useful with -l to see a directory's own owner and mode. |
-R, --recursive | Descend into every subdirectory and list it too. |
-B, --ignore-backups | Skip entries whose name ends in ~. |
--ignore=PATTERN | Skip entries whose name matches the shell pattern. May be given more than once. |
--hide=PATTERN | Like --ignore, but overridden by -a or -A — so a hide rule can be cancelled by asking to see everything. |
Sorting #
ls sorts by name by default. These options change the sort key; -r reverses whatever order is in effect.
| Option | Sort order |
|---|---|
-t | By modification time, newest first. |
-S | By size, largest first. |
-X | By file extension, alphabetically. |
-v | By version: runs of digits in the name sort numerically, so f2 comes before f10. |
-U | No sorting at all — entries appear in the order the directory stores them. Fast for very large directories. |
-r, --reverse | Reverse the current sort. |
--sort=WORD | Choose the sort key by name: none, time, size, extension, version, or width. |
--group-directories-first | List directories before other entries, with each group sorted normally. |
Output layout #
When several entries fit on a line, ls has to decide how to arrange them.
| Option | Layout |
|---|---|
-C | Columns, filled top-to-bottom. The default when writing to a terminal. |
-x | Columns, filled left-to-right (across the rows) instead. |
-1 | One entry per line. The default when output is not a terminal. |
-m | All entries on as few lines as possible, separated by commas. |
-l, --long | The long format described above. |
--format=WORD | Choose the layout by name: across, commas, horizontal, long, single-column, or vertical. |
-w, --width=COLS | Assume the terminal is COLS columns wide instead of detecting it. 0 means unlimited. |
File-type indicators and colour #
These options make a listing easier to scan by marking entries with a symbol or a colour.
| Option | Effect |
|---|---|
-F, --classify | Append a type symbol to each name: / for a directory, @ for a symlink, | for a FIFO, = for a socket, and * for an executable file. |
--file-type | The same, but without the * on executables. |
-p | Append only the / on directories. |
--indicator-style=WORD | Choose the indicator set: none, slash, file-type, or classify. |
--color[=WHEN] | Colour entries by type. WHEN is auto (colour only when writing to a terminal — the usual choice), always, or never. |
The colours ls uses are configurable. dircolors generates the colour settings and explains how to install them.
File sizes #
In the long format, and with -s, sizes are printed in bytes by default. These options rescale them.
| Option | Effect |
|---|---|
-h, --human-readable | Print sizes with a unit suffix — 4.0K, 234M, 56G — using powers of 1024. |
--si | Like -h, but using powers of 1000, so the suffixes are decimal. |
-s, --size | Print the disk space each entry occupies, in blocks, before its name. |
-k, --kibibytes | Use 1024-byte blocks for the size figures and directory totals. |
--block-size=SIZE | Scale every size by SIZE (for example --block-size=1M to count in megabytes). |
Timestamps #
The long format shows the modification time by default. These options change which timestamp is shown — and, when sorting by time, which one is sorted on.
| Option | Timestamp |
|---|---|
-u | Access time — when the file was last read. |
-c | Status-change time — when the file's metadata last changed. |
--time=WORD | Choose the timestamp by name: atime/access, ctime/status, mtime/modification, or birth/creation. |
--time-style=STYLE | Choose the date format: full-iso, long-iso, iso, locale, or +FORMAT for a custom layout. |
--full-time | Shorthand for the long format with --time-style=full-iso — a complete, unabbreviated timestamp. |
Symbolic links #
By default ls reports on a symbolic link itself — its type is l, its size is the length of the link text. These options make it report on the link's target instead.
| Option | Effect |
|---|---|
-L, --dereference | Always report on the file a link points to, not the link. |
-H, --dereference-command-line | Dereference only the links named directly on the command line, not links found inside a listed directory. |
--dereference-command-line-symlink-to-dir | Dereference a command-line link only when it points to a directory. |
readlink and realpath are the dedicated tools for inspecting and resolving links.
Other options #
The long-tail options, each in one line.
| Option | Effect |
|---|---|
-i, --inode | Print each entry's index number (its inode number). |
-Z, --context | Print each entry's security context. Available only when the build enables it. |
-b, --escape | Print non-printable characters in names using C-style backslash escapes. |
-q, --hide-control-chars | Replace non-printable characters in names with ?. The default when writing to a terminal. |
--show-control-chars | Print names verbatim, control characters and all. |
-N, --literal | Print names exactly, with no quoting. |
-Q, --quote-name | Wrap each name in double quotes. |
--quoting-style=WORD | Choose the quoting scheme: literal, shell, shell-always, shell-escape, c, escape, and others. |
-T, --tabsize=COLS | Assume tab stops every COLS columns when laying out output. |
-f | List everything, unsorted, including hidden entries — equivalent to -aU. Also turns colour off unless --color is given explicitly. |
--hyperlink[=WHEN] | Emit terminal hyperlinks for file names, so a capable terminal can make them clickable. |
-D, --dired | Emit extra position markers designed for the Emacs dired editing mode. |
--zero | End each line with a NUL character instead of a newline, and list one entry per line. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success — everything requested was listed. |
1 | A minor problem — for example, a named file could not be accessed. The rest of the listing still printed. |
2 | A serious problem — for example, a directory could not be read, or an option was invalid. |
dir and vdir
Peios / Using Peios / Peiosutils / Listing and paths
dir and vdir list the contents of a directory. They are ls with one decision already made for you: the output style is fixed instead of adapting to where the output is going.
dir [options] [file...]
vdir [options] [file...]
What they fix #
ls chooses its layout based on whether it is writing to a terminal — columns for a terminal, one name per line for a pipe or file. dir and vdir each pick one layout and always use it:
| Command | Layout | Equivalent ls |
|---|---|---|
dir | Columns, regardless of where output goes. | ls -C |
vdir | Long format, regardless of where output goes. | ls -l |
Both also default to a quoting style that escapes unusual characters in names without wrapping every name in quotes.
That is the only difference. The fixed style is just a default — pass an explicit format option (-1, -m, -l, -C, …) and it overrides the built-in choice, so dir -l produces a long listing and vdir -C produces columns.
Everything else is ls #
dir and vdir accept every option ls accepts and behave identically in every other respect — sorting, filtering, the long-format columns, colour, indicators, sizes, timestamps. vdir's long format is the same Peios long format documented for ls, with the [type][executable][protected] mode column and the owner SID.
For the full option set and the meaning of every long-format column, see ls. This page exists only to explain how dir and vdir differ from it — and the answer is "they pin the output style."
Exit status #
The same as ls:
| Code | Meaning |
|---|---|
0 | Success. |
1 | A minor problem — a named file could not be accessed. |
2 | A serious problem — a directory could not be read, or an option was invalid. |
stat
Peios / Using Peios / Peiosutils / Listing and paths
stat displays the detailed status of a file. Where ls gives you a line per file, stat gives you everything the system records about one file: its type, its size, its timestamps, and the low-level metadata stored in its inode.
stat [options] file...
stat is a diagnostic tool. It dumps what a file's inode physically contains, verbatim. That makes it the right command for "tell me exactly what is recorded about this file" — and it means some of what it prints needs a word of explanation, below.
The default output #
With no options, stat prints a labelled, multi-line block per file:
$ stat notes.txt
File: notes.txt
Size: 219 Blocks: 8 IO Block: 4096 regular file
Device: 8,2 Inode: 1572931 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 1000/ jack) Gid: ( 1000/ jack)
Access: 2026-05-15 11:03:42.000000000 +0000
Modify: 2026-05-15 11:03:42.000000000 +0000
Change: 2026-05-15 11:03:18.000000000 +0000
Birth: 2026-05-15 11:03:18.000000000 +0000
Reading it line by line:
- File — the name, and for a symbolic link, what it points to.
- Size / Blocks / IO Block / type — the size in bytes, the number of allocated disk blocks, the preferred I/O block size, and the file type in words.
- Device / Inode / Links — the device the file lives on, its inode number (its unique index within that device), and the number of hard links to it.
- Access / Uid / Gid — a numeric owner id, a group id, and a mode value. See the caveat below — these are not the access policy.
- Access / Modify / Change / Birth — four timestamps: when the file was last read, last written, last had its metadata changed, and first created.
The Uid, Gid, and mode fields are decorative #
The Access: (0644/-rw-r--r--) Uid: (…) Gid: (…) line needs care.
A file's inode carries a numeric owner id, a numeric group id, and a mode value, and stat reports them because stat reports the inode verbatim. But the Peios access model does not consult these fields. What a principal may do to a file is decided by the file's security descriptor — see Security descriptors. The numbers on the Uid/Gid/mode line are inert metadata: they are stored, they are reported, and they have no effect on any access decision.
Do not read that line as a permission summary. A file showing 0644 is not "world-readable" in any meaningful sense — whether anyone can read it depends entirely on its security descriptor. stat shows these fields for completeness as a low-level diagnostic; it does not claim they mean anything.
To see the parts of a file's security that do count, use ls -l, which shows the owner SID and the mode column built from the security descriptor.
Custom output formats #
Two options replace the default block with output you control.
| Option | Effect |
|---|---|
-c FORMAT, --format=FORMAT | Print FORMAT for each file, substituting % directives. A newline is added after each file. |
--printf=FORMAT | Like --format, but interpret backslash escapes (\n, \t) in FORMAT and add no trailing newline. Include \n yourself if you want one. |
$ stat --format='%n is %s bytes' notes.txt
notes.txt is 219 bytes
File directives #
Used in the format string when stating files (the default mode):
| Directive | Substitutes |
|---|---|
%n | File name. |
%N | Quoted file name; for a symlink, with the target shown. |
%F | File type in words (regular file, directory, …). |
%s | Total size, in bytes. |
%b | Number of allocated blocks. |
%B | Size in bytes of each block counted by %b. |
%o | Preferred I/O transfer block size. |
%i | Inode number. |
%h | Number of hard links. |
%d / %D | Device number, in decimal / in hexadecimal. |
%t / %T | For a device file, the major / minor device type, in hexadecimal. |
%m | Mount point of the file system the file is on. |
%f | Raw mode value, in hexadecimal. |
%x / %X | Last access time — human-readable / seconds since the epoch. |
%y / %Y | Last modification time — human-readable / seconds since the epoch. |
%z / %Z | Last status-change time — human-readable / seconds since the epoch. |
%w / %W | File creation (birth) time — human-readable / seconds since the epoch. - or 0 if unknown. |
The directives %a and %A (the mode in octal and in symbolic form) and %u, %U, %g, %G (the owner and group, numeric and by name) report the decorative inode fields described above. They are available for completeness; they are not the file's access policy.
The %C directive (security context) is inactive — it produces no meaningful value on a standard Peios system.
File-system directives #
With -f, stat reports on the file system a file lives on rather than the file itself, and the format string uses a different directive set:
| Directive | Substitutes |
|---|---|
%n | File name. |
%i | File-system ID, in hexadecimal. |
%t / %T | File-system type — in hexadecimal / in words. |
%l | Maximum length of a file name. |
%s | Block size, for fast transfers. |
%S | Fundamental block size, used for the block counts. |
%b | Total data blocks in the file system. |
%f | Free blocks. |
%a | Free blocks available to an ordinary principal. |
%c | Total inodes (file nodes). |
%d | Free inodes. |
Options #
| Option | Effect |
|---|---|
-f, --file-system | Report on the file system containing each file, instead of the file. |
-L, --dereference | Follow symbolic links — report on the link's target rather than the link itself. |
-t, --terse | Print the information on a single line, as bare values with no labels. Useful for scripts. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was stated successfully. |
1 | A file could not be stated, or an option or format directive was invalid. |
pwd
Peios / Using Peios / Peiosutils / Listing and paths
pwd — "print working directory" — prints the absolute path of the directory you are currently in.
pwd [options]
$ pwd
/home/jack/projects
Every process has a working directory: the directory that relative path names are interpreted against. pwd tells you what yours is.
Physical and logical paths #
There are two honest answers to "where am I", and they differ only when symbolic links are involved.
Suppose /home/jack/work is a symbolic link pointing at /data/projects/jack, and you move into it. The physical path — the real location, with every link resolved — is /data/projects/jack. The logical path — the route you took to get there — is /home/jack/work.
| Option | Path printed |
|---|---|
-P, --physical | The physical path: every symbolic link resolved to its target. |
-L, --logical | The logical path: the route recorded as you navigated, taken from the PWD environment variable when it is accurate. |
By default pwd prints the physical path — -P is the default, and naming it explicitly just makes that choice visible. Use -L when you want the path as you navigated it, links and all.
If -L is requested but the recorded PWD value is missing or does not actually match the current directory, pwd falls back to the physical path rather than print something wrong.
A note on shells #
Many command shells provide their own built-in pwd, and the shell's version is what runs when you type pwd at a prompt. The two behave the same for everyday use. To be certain you are running this command rather than the shell built-in, invoke it by its full path.
Exit status #
| Code | Meaning |
|---|---|
0 | The working directory was printed. |
1 | The working directory could not be determined or could not be printed. |
readlink
Peios / Using Peios / Peiosutils / Listing and paths
readlink prints where a symbolic link points.
readlink [options] file...
A symbolic link is a file whose contents are another path. readlink reads that path and prints it:
$ readlink /home/jack/work
/data/projects/jack
By default readlink resolves exactly one level: it prints the link's immediate target, whether or not that target is itself a link, and whether or not it exists. If the named file is not a symbolic link, readlink prints nothing and reports failure.
Canonicalising a whole path #
The canonicalise options change readlink from "read this one link" into "resolve this entire path to its real, absolute form" — following every symbolic link in every component, collapsing . and ... The three differ only in how strict they are about components existing:
| Option | Resolves | Existence requirement |
|---|---|---|
-f, --canonicalize | Every link in the path. | Every component except the last must exist. |
-e, --canonicalize-existing | Every link in the path. | Every component must exist. |
-m, --canonicalize-missing | Every link in the path. | No component need exist. |
With any of these, readlink succeeds on an ordinary (non-link) file too — it simply returns the canonical path. realpath is the dedicated command for this canonicalising job and has more options for it; the canonicalise flags here exist so readlink can do it without a second tool.
Output and error control #
| Option | Effect |
|---|---|
-n, --no-newline | Do not print the trailing newline. Ignored, with a warning, when more than one file is given. |
-z, --zero | End each output line with a NUL character instead of a newline. |
-q, --quiet / -s, --silent | Suppress most error messages. This is the default. |
-v, --verbose | Report error messages that the quiet default would suppress. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every named file was resolved and printed. |
1 | A file was not a symbolic link, or a path could not be resolved under the chosen strictness. |
realpath
Peios / Using Peios / Peiosutils / Listing and paths
realpath resolves a path to its canonical form: absolute, with every symbolic link followed and every . and .. component removed. Given any path, it tells you the one true location it refers to.
realpath [options] file...
$ realpath ../work/./notes.txt
/data/projects/jack/notes.txt
A single file can be named many ways — through links, through relative paths, with redundant components. realpath reduces all of them to the single canonical path, which makes it the right tool for comparing two paths, or for turning a path a user typed into one a script can rely on.
How much must exist #
By default realpath requires every component of the path except the last to exist. These options change that requirement:
| Option | Existence requirement |
|---|---|
| (default) | Every component except the last must exist. |
-e, --canonicalize-existing | Every component must exist — including the final one. |
-m, --canonicalize-missing | No component need exist. The path is canonicalised purely as text where it cannot be walked. |
How symbolic links are handled #
| Option | Effect |
|---|---|
-P, --physical | Resolve each symbolic link as it is encountered. This is the default. |
-L, --logical | Resolve .. components before resolving the symbolic links they follow. |
-s, --strip, --no-symlinks | Do not resolve symbolic links at all — only remove . and .. components. The result is canonical as text, but a link in the path is left as-is. |
Output options #
| Option | Effect |
|---|---|
--relative-to=DIR | Print the result as a path relative to DIR instead of as an absolute path. |
--relative-base=DIR | Print an absolute path, unless the result lies inside DIR, in which case print it relative to DIR. |
-z, --zero | End each output line with a NUL character instead of a newline. |
-q, --quiet | Do not print a warning when a path is invalid. The exit status still reports the failure. |
realpath and readlink #
readlink with -f/-e/-m does the same canonicalising job. The difference is focus: readlink is primarily for reading a single link's target, with canonicalising as an extra; realpath is built for canonicalising and carries the richer option set — relative output, the symlink-handling modes, the strip-only mode. Use realpath when canonicalising is the actual goal.
Exit status #
| Code | Meaning |
|---|---|
0 | Every path was resolved and printed. |
1 | A path could not be resolved under the chosen options. |
basename
Peios / Using Peios / Peiosutils / Listing and paths
basename takes a path and prints just its final component — the file name, with all the leading directories removed.
basename name [suffix]
basename [options] name...
$ basename /home/jack/projects/notes.txt
notes.txt
It is a pure text operation: basename never looks at the file system. It works on the string you give it, so the path need not exist.
Removing a suffix #
Give a second argument and basename also strips that suffix from the end of the name — handy for turning a file name into a bare stem:
$ basename /home/jack/projects/notes.txt .txt
notes
The suffix is only removed if it is actually there, and never if it would leave an empty string.
Options #
basename takes one name by default. The options let it process several at once.
| Option | Effect |
|---|---|
-a, --multiple | Treat every argument as a name to process, instead of treating the second argument as a suffix. Required to pass more than one name. |
-s, --suffix=SUFFIX | Remove SUFFIX from each name. Implies -a, so it applies to every name given. |
-z, --zero | End each output line with a NUL character instead of a newline. |
$ basename -s .txt notes.txt readme.txt changelog.txt
notes
readme
changelog
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A usage error — a missing or extra operand. |
dirname
Peios / Using Peios / Peiosutils / Listing and paths
dirname takes a path and prints everything except its final component — the directory the name lives in. It is the counterpart of basename.
dirname [options] name...
$ dirname /home/jack/projects/notes.txt
/home/jack/projects
Like basename, it is a pure text operation — dirname never touches the file system, so the path need not exist.
How it handles edge cases #
- If the name has no
/in it at all, there is no directory part, sodirnameprints.— the current directory. - Trailing slashes on the name are ignored before the final component is removed.
$ dirname notes.txt
.
$ dirname /home/jack/projects/
/home
Multiple names #
dirname accepts any number of names and prints the directory part of each on its own line:
$ dirname /etc/hosts /var/log/messages report.txt
/etc
/var/log
.
Options #
| Option | Effect |
|---|---|
-z, --zero | End each output line with a NUL character instead of a newline. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A usage error — no name was given. |
pathchk
Peios / Using Peios / Peiosutils / Listing and paths
pathchk checks whether a path name is valid and usable. It tells you, before you try to create a file, whether the name would be rejected — because it is too long, contains an unusable component, or could not be reached.
pathchk [options] name...
$ pathchk /home/jack/projects/notes.txt
$ echo $?
0
pathchk prints nothing when a name is fine; it prints a diagnostic and fails when a name is not. It is meant for scripts that build up path names and want to fail early, with a clear message, instead of discovering the problem halfway through an operation.
Like the other name commands in this topic, pathchk does not require the file to exist — it is checking the name, not looking for the file.
What the default check covers #
With no options, for each name pathchk checks:
- that the name is not empty;
- that no part of the name exceeds the length limits of the file systems the path would actually touch;
- that the leading directories of the name can be searched.
Stricter checks #
The options replace "valid on this system" with "valid across a wide range of systems" — useful when a name has to work somewhere other than where you are checking it.
| Option | Adds |
|---|---|
-p | Check against a fixed, conservative set of length limits instead of the local file system's, and flag characters that are not broadly portable. |
-P | Reject an empty name, and reject any component that begins with -. |
--portability | Apply both -p and -P — the strictest check. |
A name with a leading - on a component is worth catching: many commands would read it as an option rather than a file name. -P flags exactly that class of trouble.
Exit status #
| Code | Meaning |
|---|---|
0 | Every name passed every requested check. |
1 | At least one name failed. A diagnostic naming the problem was printed. |
dircolors
Peios / Using Peios / Peiosutils / Listing and paths
When ls colours its output, it reads the colours from an environment variable named LS_COLORS. dircolors is the command that produces the value of that variable.
dircolors [options] [file]
dircolors does not set the variable itself — a command cannot change its parent shell's environment. Instead it prints shell commands that, when run by the shell, set LS_COLORS. The usual way to use it is to have the shell evaluate that output:
eval "$(dircolors)"
Put that line in a shell startup file and every ls --color afterwards picks up the colours. With no arguments, dircolors uses a built-in default colour scheme.
Choosing the shell syntax #
The commands dircolors prints have to match the shell that will run them. By default it guesses the syntax from the SHELL environment variable; these options state it outright.
| Option | Output syntax |
|---|---|
-b, --sh, --bourne-shell | Bourne-shell-family syntax. |
-c, --csh, --c-shell | C-shell-family syntax. |
If no shell option is given and SHELL is not set, dircolors cannot guess and reports an error.
Customising the colours #
To change the colours, you start from the default scheme, edit it, and feed it back.
| Option | Effect |
|---|---|
-p, --print-database | Print the built-in colour database in its editable source form, instead of shell commands. |
--print-ls-colors | Print the colours fully escaped, one per line, for inspection. |
The workflow is:
dircolors -p > ~/.dircolors # save the default scheme to a file
# ...edit ~/.dircolors to taste...
eval "$(dircolors ~/.dircolors)" # load the edited scheme
When dircolors is given a file argument, it reads the colour definitions from that file instead of using the built-in database. The file maps file types and name extensions to colours; dircolors -p shows the format, with each line commented.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A usage error, or a colour-definition file that could not be read or parsed. |
Viewing and joining text
Peios / Using Peios / Peiosutils / Viewing and joining text
Much of the work at a command line is reading text — looking at a file, checking the start or end of one, pulling a column out, stitching two files together. This topic covers the commands for that: viewing files, showing parts of them, and joining or splitting text by line and by column.
This page names every command in the topic. The companion topic, Transforming text, covers the commands that reshape text — sorting, de-duplicating, substituting characters.
The commands #
Viewing a whole file
| Command | Purpose |
|---|---|
cat | Print one or more files straight through — and join several into one. |
tac | Print a file with its lines in reverse — last line first. |
more | Show a file one screen at a time, pausing between screens. |
od | Dump a file's raw bytes in octal, hex, decimal, or character form. |
Viewing part of a file
| Command | Purpose |
|---|---|
head | Print the first lines (or bytes) of a file. |
tail | Print the last lines of a file — and, with -f, keep printing as the file grows. |
nl | Print a file with its lines numbered. |
Cutting and combining
| Command | Purpose |
|---|---|
cut | Pull selected columns — byte ranges or delimited fields — out of each line. |
paste | Join files side by side, line for line. |
join | Join two files on a shared field, like a database join. |
comm | Compare two sorted files and report which lines they share. |
Splitting a file into files
| Command | Purpose |
|---|---|
split | Break a file into equal-sized pieces. |
csplit | Break a file into pieces at lines that match a pattern. |
A note on input #
Almost every command here follows the same convention for where its text comes from. Name one or more files and it reads those; name none and it reads from standard input, so it can sit in the middle of a pipeline. Where a command accepts files, the name - stands for standard input, so you can mix files and piped input in one invocation.
Where to start #
To simply print a file, or join a few together, read cat — the most-used command in the topic.
cat
Peios / Using Peios / Peiosutils / Viewing and joining text
cat prints the contents of files. Its name is short for "concatenate" — given several files, it prints them one after another, as a single stream.
cat [options] [file...]
$ cat notes.txt
$ cat part1.txt part2.txt part3.txt > whole.txt
With no file — or with the name - — cat reads standard input, which is what makes it useful in a pipeline or for capturing typed input into a file.
Plain use #
Most of the time cat is run with no options at all: it copies its input to its output, byte for byte, unchanged. The options exist for the times you want to see something about the text that is normally invisible, or to adjust spacing and numbering.
Numbering lines #
| Option | Effect |
|---|---|
-n, --number | Number every output line. |
-b, --number-nonblank | Number only the non-empty lines. Overrides -n. |
Making invisible characters visible #
These options reveal characters that normally print as nothing, or as whitespace — useful when a file is not behaving and you suspect a stray tab or control character.
| Option | Effect |
|---|---|
-E, --show-ends | Print a $ at the end of each line, so trailing spaces become visible. |
-T, --show-tabs | Print tab characters as ^I instead of as whitespace. |
-v, --show-nonprinting | Print control and other non-printing characters using ^ and M- notation (leaving newline and tab as they are). |
-e | Shorthand for -vE. |
-t | Shorthand for -vT. |
-A, --show-all | Shorthand for -vET — show ends, tabs, and all non-printing characters at once. |
Adjusting spacing #
| Option | Effect |
|---|---|
-s, --squeeze-blank | Collapse runs of blank lines into a single blank line. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was printed. |
1 | A file could not be read. |
tac
Peios / Using Peios / Peiosutils / Viewing and joining text
tac prints a file with its lines in reverse — the last line first, the first line last. The name is cat spelled backwards, which is exactly what it does.
tac [options] [file...]
$ tac events.log
Reversing a log file so the newest entries come first is the everyday use. With no file, tac reads standard input.
Reversing on something other than lines #
tac does not have to split on newlines. You can tell it to treat some other string as the separator, and it will reverse the order of the pieces between those separators.
| Option | Effect |
|---|---|
-s, --separator=STRING | Use STRING as the separator between records, instead of a newline. |
-r, --regex | Treat the separator as a regular expression rather than a literal string. |
-b, --before | Expect the separator before each record rather than after it. This matters for how the separator is reattached when the records are reordered. |
$ tac -s ', ' -r names.csv
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was printed. |
1 | A file could not be read, or the separator regular expression was invalid. |
head
Peios / Using Peios / Peiosutils / Viewing and joining text
head prints the beginning of a file — by default, the first 10 lines.
head [options] [file...]
$ head config.toml
It is the quick way to glance at the top of a file without printing the whole thing — a header, the first few records, the start of a log. With no file, head reads standard input.
Choosing how much #
| Option | Effect |
|---|---|
-n, --lines=NUM | Print the first NUM lines instead of 10. |
-c, --bytes=NUM | Print the first NUM bytes instead of counting lines. |
NUM accepts a unit suffix (K, M, …) when counting bytes.
Counting from the end #
If NUM is given a leading -, the meaning flips: head prints everything except the last NUM.
$ head -n -5 report.txt # all but the final 5 lines
Headers for multiple files #
When given more than one file, head prints a header line before each one so you can tell them apart. Two options override that:
| Option | Effect |
|---|---|
-q, --quiet | Never print the file-name headers. |
-v, --verbose | Always print the header, even for a single file. |
Other options #
| Option | Effect |
|---|---|
-z, --zero-terminated | Treat the NUL character as the line delimiter instead of newline. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was printed. |
1 | A file could not be read. |
tail
Peios / Using Peios / Peiosutils / Viewing and joining text
tail prints the end of a file — by default, the last 10 lines.
tail [options] [file...]
$ tail server.log
It is the counterpart of head: the quick way to see the most recent lines of a file. With no file, tail reads standard input.
Choosing how much #
| Option | Effect |
|---|---|
-n, --lines=NUM | Print the last NUM lines instead of 10. |
-c, --bytes=NUM | Print the last NUM bytes instead of counting lines. |
If NUM is given a leading +, the meaning flips: tail prints everything from line NUM onward.
$ tail -n +20 report.txt # from line 20 to the end
Following a growing file #
tail's most useful trick is -f. Instead of printing the end and exiting, it stays running and prints new lines as they are appended — the standard way to watch a log file live.
| Option | Effect |
|---|---|
-f, --follow | Keep the file open and print new data as it is added. |
-F | Follow the file by name, and keep retrying if it is missing. Equivalent to --follow=name --retry. This survives log rotation — when the file is replaced, -F picks up the new one. |
--retry | Keep trying to open a file that is not yet accessible. |
--pid=PID | While following, stop once process PID exits. |
-s, --sleep-interval=N | Wait N seconds between checks of the file. |
Press Ctrl-C to stop a tail -f.
Headers for multiple files #
As with head, when given more than one file tail prints a header before each.
| Option | Effect |
|---|---|
-q, --quiet | Never print the file-name headers. |
-v, --verbose | Always print the header, even for a single file. |
Other options #
| Option | Effect |
|---|---|
-z, --zero-terminated | Treat the NUL character as the line delimiter instead of newline. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read. |
more
Peios / Using Peios / Peiosutils / Viewing and joining text
more displays a file one screenful at a time. Where cat prints a whole file at once — and a long file scrolls straight past — more shows the first screen, then waits for you before showing the next.
more [options] file...
$ more long-report.txt
Moving through the file #
While more is paused, it is waiting for a keypress:
| Key | Action |
|---|---|
| Space | Show the next screenful. |
| Return | Show the next line. |
q | Quit. |
/ | Search forward for a string. |
h | Show the built-in help. |
more moves forward through a file. It is the simple, always-available pager — enough for reading something through once.
Options #
| Option | Effect |
|---|---|
-n, --lines=NUM | Show NUM lines per screenful instead of filling the terminal. --number=NUM is the same. |
-F, --from-line=NUM | Begin displaying at line NUM. |
-P, --pattern=STRING | Search for STRING and begin displaying at the first match. |
-e, --exit-on-eof | Exit automatically at the end of the file, rather than waiting. |
-s, --squeeze | Collapse runs of blank lines into one. |
-u, --plain | Suppress underlining in the displayed text. |
-p, --print-over | Clear the screen and print the next page, instead of scrolling. |
-c, --clean-print | Redraw each page in place, cleaning line ends, instead of scrolling. |
-d, --silent | When an unrecognised key is pressed, show a short hint instead of ringing the terminal bell. |
-l, --logical | Do not pause when a line contains a form-feed character. |
-f, --no-pause | Count logical lines rather than screen lines when deciding where to pause. |
Exit status #
| Code | Meaning |
|---|---|
0 | The file was displayed. |
1 | A file could not be opened. |
nl
Peios / Using Peios / Peiosutils / Viewing and joining text
nl prints a file with its lines numbered. cat -n also numbers lines; nl is the command for when you need control over the numbering — which lines count, how the number looks, where it restarts.
nl [options] [file...]
$ nl chapter.txt
1 Once the system is installed,
2 the first boot brings up
3 the configuration service.
Which lines get a number #
By default nl numbers only the non-empty lines. -b sets the rule:
-b STYLE | Numbers… |
|---|---|
-b a | every line. |
-b t | only non-empty lines. This is the default. |
-b n | no lines. |
-b pBRE | only lines that match the basic regular expression BRE. |
| Option | Effect |
|---|---|
-b, --body-numbering=STYLE | The numbering rule, as above. |
-l, --join-blank-lines=N | Count a run of N blank lines as one numbered line. |
How the number looks #
| Option | Effect |
|---|---|
-n, --number-format=FORMAT | ln = left-justified; rn = right-justified; rz = right-justified with leading zeros. |
-w, --number-width=N | Use N columns for the number. |
-s, --number-separator=STRING | Put STRING between the number and the line text. |
-v, --starting-line-number=N | Start counting from N. |
-i, --line-increment=N | Increase the number by N at each counted line. |
Logical pages #
nl can treat one file as a sequence of logical pages, each with a header, a body, and a footer, and number the three parts by different rules. Pages are separated by special delimiter lines in the input.
| Option | Effect |
|---|---|
-h, --header-numbering=STYLE | Numbering style for header sections. |
-f, --footer-numbering=STYLE | Numbering style for footer sections. |
-d, --section-delimiter=CC | The characters that mark a section boundary in the input. |
-p, --no-renumber | Do not reset the line number at the start of each logical page. |
The styles for -h and -f are the same a / t / n / pBRE set as -b. For an ordinary file with no delimiter lines, the whole file is one body and only -b matters.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or an option value was invalid. |
cut
Peios / Using Peios / Peiosutils / Viewing and joining text
cut extracts columns from text. For each line of input it prints only the parts you select, dropping the rest.
cut option... [file...]
$ cut -d , -f 1,3 people.csv # the 1st and 3rd comma-separated field
$ cut -c 1-8 log.txt # the first 8 characters of each line
Every run of cut needs two decisions: what counts as a column, and which columns to keep.
What counts as a column #
Choose exactly one mode:
| Option | A column is… |
|---|---|
-b, --bytes | a single byte. |
-c, --characters | a single character. |
-f, --fields | a field — a stretch of text between delimiters. |
-b and -c cut by position. -f cuts by field, which is what you want for tabular data — a CSV, a table, the columns of a report.
Which columns to keep #
The mode option takes a sequence: numbers and inclusive ranges, separated by commas.
| Sequence | Selects |
|---|---|
3 | column 3 only. |
2,5,9 | columns 2, 5, and 9. |
5-7 | columns 5 through 7. |
3- | column 3 to the end of the line. |
-4 | the start of the line through column 4. |
1,4-6,9 | any mixture of the above. |
| Option | Effect |
|---|---|
--complement | Invert the selection — keep every column except those named. |
Field mode: the delimiter #
In field mode, cut needs to know what separates the fields.
| Option | Effect |
|---|---|
-d, --delimiter=CHAR | The character that separates fields. The default is a tab. |
-w | Separate fields on runs of whitespace (spaces and tabs) instead of a single character. Cannot be combined with -d. |
-s, --only-delimited | Print only lines that actually contain the delimiter; drop lines that have no fields to cut. |
--output-delimiter=STRING | Put STRING between the kept fields in the output, instead of repeating the input delimiter — handy for converting one delimited format to another. |
Other options #
| Option | Effect |
|---|---|
-z, --zero-terminated | Treat the NUL character as the line delimiter, so a "line" may itself contain newlines. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A mode was missing or given twice, an option was misused, or a file could not be read. |
paste
Peios / Using Peios / Peiosutils / Viewing and joining text
paste joins files side by side. It takes the first line of each file and prints them on one line, then the second line of each, and so on — merging files into columns.
paste [options] [file...]
$ paste names.txt ages.txt
Ada 36
Grace 41
Linus 29
By default the merged pieces are separated by a tab. paste is the column-wise counterpart of cat, which joins files end to end.
Pasting one file at a time #
| Option | Effect |
|---|---|
-s, --serial | Paste each file's lines onto a single line, one file at a time, instead of merging files in parallel. A file's lines become a row. |
$ paste -s -d , names.txt
Ada,Grace,Linus
Choosing the separator #
| Option | Effect |
|---|---|
-d, --delimiters=LIST | Use the characters in LIST as separators instead of a tab. When LIST has more than one character, paste cycles through them. |
-z, --zero-terminated | Treat the NUL character as the line delimiter instead of newline. |
paste and join #
paste merges by position — line 1 with line 1, line 2 with line 2, blind to content. When you want to merge by a matching value — pairing rows that share a key — that is join.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or the delimiter list was malformed. |
join
Peios / Using Peios / Peiosutils / Viewing and joining text
join merges two files on a shared field. For every pair of lines — one from each file — that have the same value in their join field, it prints a combined line. It is the command-line form of a database join.
join [options] file1 file2
$ join employees.txt salaries.txt
If employees.txt has 101 Ada and salaries.txt has 101 80000, join pairs them on the shared 101 and prints 101 Ada 80000.
The input must be sorted #
join works by stepping through both files together, so both files must be sorted on the join field. If they are not, join will miss matches. Sort them first with sort, on the same field join will use.
join checks the ordering as it goes and reports a file that is out of order; --nocheck-order turns that check off, and --check-order forces it on.
Choosing the join field #
By default join joins on the first field of each file, and fields are separated by whitespace.
| Option | Effect |
|---|---|
-1 FIELD | Join on FIELD of file 1. |
-2 FIELD | Join on FIELD of file 2. |
-j FIELD | Join on FIELD of both files — shorthand for -1 FIELD -2 FIELD. |
-t CHAR | Use CHAR as the field separator for input and output. |
Unmatched lines #
By default join prints only the lines that paired. A line with no match on the other side is dropped. These options bring unmatched lines back:
| Option | Effect |
|---|---|
-a FILENUM | Also print unpaired lines from file FILENUM (1 or 2). |
-v FILENUM | Print only the unpaired lines from file FILENUM, and suppress the joined output. |
-e EMPTY | Fill in any missing field with the string EMPTY. |
Shaping the output #
| Option | Effect |
|---|---|
-o FORMAT | Build each output line from the field list FORMAT, rather than the default layout. |
-i, --ignore-case | Ignore letter case when comparing join fields. |
--header | Treat the first line of each file as column headers — print them, paired, without trying to match them. |
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, was not sorted, or an option was invalid. |
comm
Peios / Using Peios / Peiosutils / Viewing and joining text
comm compares two sorted files and tells you which lines are unique to each and which are common to both.
comm [options] file1 file2
By default it prints three columns:
$ comm list-a.txt list-b.txt
apple
banana
cherry
date
- Column 1 — lines only in
file1. - Column 2 — lines only in
file2. - Column 3 — lines in both.
Each column is indented past the one before, so a line's column position tells you where it was found.
The input must be sorted #
comm steps through both files together, so both must be sorted. On unsorted input the comparison is meaningless. Sort the files first with sort.
comm checks the ordering and reports a file that is out of order. --check-order forces the check; --nocheck-order disables it.
Showing only the columns you want #
Each column can be switched off. The remaining columns close up to fill the gap.
| Option | Effect |
|---|---|
-1 | Suppress column 1 — hide lines unique to file1. |
-2 | Suppress column 2 — hide lines unique to file2. |
-3 | Suppress column 3 — hide lines common to both. |
These combine to answer specific questions. comm -12 shows only the common lines — the intersection of the two files. comm -23 shows lines in file1 that are not in file2 — the difference.
Other options #
| Option | Effect |
|---|---|
--output-delimiter=STR | Separate the columns with STR instead of spaces. |
--total | Print a final summary line with the count in each column. |
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or was not sorted. |
split
Peios / Using Peios / Peiosutils / Viewing and joining text
split breaks one file into several smaller files of roughly equal size.
split [options] [input [prefix]]
$ split -l 1000 big.log
$ ls
xaa xab xac xad
By default split writes 1000 lines per piece, names the pieces xaa, xab, xac, … — a prefix of x followed by a two-letter suffix — and reads its input from a named file or from standard input.
To put the file back together, concatenate the pieces in order: cat xaa xab xac … > original.
Each piece is a newly created file, and a new file needs a security descriptor; each piece inherits one from the directory it is created in.
How big each piece is #
Choose one of these to set the piece size:
| Option | Each piece holds… |
|---|---|
-l, --lines=N | N lines. This is the default behaviour, with N = 1000. |
-b, --bytes=SIZE | SIZE bytes. |
-C, --line-bytes=SIZE | up to SIZE bytes, but only whole lines — never a line split across two pieces. |
-n, --number=CHUNKS | the input divided into a fixed number of pieces (see below). |
A SIZE accepts a unit suffix: K, M, G, … as powers of 1024, or KB, MB, … as powers of 1000.
Fixed number of chunks (-n) #
-n divides the input into a set number of pieces rather than fixing each piece's size. CHUNKS may be:
| Form | Effect |
|---|---|
N | Split into N pieces by size. |
l/N | Split into N pieces without splitting any line across pieces. |
r/N | Like l/N, but distribute lines round-robin across the pieces. |
K/N | Write only the Kth of N pieces, to standard output. |
Naming the pieces #
| Option | Effect |
|---|---|
prefix (operand) | The leading part of each output name. Default x. |
-a, --suffix-length=N | Use N characters for the suffix. Default 2. |
-d, --numeric-suffixes[=START] | Use numeric suffixes (00, 01, …) instead of letters, optionally from START. |
-x, --hex-suffixes[=START] | Use hexadecimal suffixes. |
--additional-suffix=SUFFIX | Append a fixed SUFFIX to every output name — for giving the pieces an extension. |
Other options #
| Option | Effect |
|---|---|
-e, --elide-empty-files | With -n, do not write out pieces that would be empty. |
-t, --separator=SEP | Use SEP as the line separator instead of newline. |
--verbose | Print a line as each output file is opened. |
Exit status #
| Code | Meaning |
|---|---|
0 | The file was split. |
1 | A failure — the input could not be read, or the suffixes were exhausted. |
csplit
Peios / Using Peios / Peiosutils / Viewing and joining text
csplit — "context split" — breaks a file into pieces at points you choose: a line number, or a line that matches a pattern. Where split cuts a file into equal sizes, csplit cuts it at meaningful boundaries.
csplit [options] file pattern...
$ csplit report.txt '/^Chapter/' '{*}'
That splits report.txt into one piece per chapter, cutting at every line beginning with Chapter. The pieces are written to xx00, xx01, xx02, … and csplit prints the byte size of each.
Each piece is a newly created file, and a new file needs a security descriptor; each piece inherits one from the directory it is created in.
Patterns #
Each pattern argument marks a cut point. csplit copies everything up to that point into the next output file, then continues.
| Pattern | Cut where… |
|---|---|
N (a number) | line N is reached — the piece is lines up to N-1. |
/REGEX/ | a line matches REGEX; that line starts the next piece. |
%REGEX% | a line matches REGEX — but skip the text up to it instead of writing it to a piece. |
/REGEX/+N or /REGEX/-N | an offset of N lines from the matching line. |
{N} | repeat the previous pattern N more times. |
{*} | repeat the previous pattern as many times as the file allows. |
A pattern that finds no match is an error, and by default csplit removes the pieces it had already written. -k keeps them.
Naming the pieces #
| Option | Effect |
|---|---|
-f, --prefix=PREFIX | Use PREFIX instead of xx at the start of each output name. |
-n, --digits=N | Use N digits in the numeric part of the name instead of 2. |
-b, --suffix-format=FORMAT | Use a custom printf-style FORMAT for the numeric suffix. |
Other options #
| Option | Effect |
|---|---|
-k, --keep-files | Do not delete the output files if csplit fails partway. |
-z, --elide-empty-files | Do not write out pieces that would be empty. |
--suppress-matched | Drop the lines that the patterns matched, rather than keeping them in a piece. |
-s, --quiet | Do not print the byte size of each piece. |
Exit status #
| Code | Meaning |
|---|---|
0 | The file was split. |
1 | A pattern did not match, a line number was out of range, or the input could not be read. |
od
Peios / Using Peios / Peiosutils / Viewing and joining text
od — short for "octal dump" — prints the raw bytes of a file. Where cat shows you a file as text, od shows you the actual bytes underneath: each one rendered as a number, a character, or a floating-point value, in whatever base you ask for. It is the tool for looking at binary files, checking exactly which bytes a text file contains, or finding a stray control character that isn't visible on screen.
od [options] [file...]
$ od hello.txt
0000000 062510 066154 020157 067527 066162 020144 000012
0000015
With no file — or with the name - — od reads standard input, so it can sit at the end of a pipeline. Given several files, it reads them one after another as a single continuous stream, and the byte offsets run straight through from one file into the next.
How the output is laid out #
Every line of output has two parts:
- An offset on the left: the position, counted in bytes from the start of the input, of the first byte on that line. By default it is printed in octal (see
-Ato change the base). - One or more format columns on the right: the bytes of that line rendered in the format(s) you chose.
With no format option, od prints the input as octal, two bytes at a time — the traditional default. The very last line of output is a lone offset marking the total length of the input.
If you ask for more than one format at once, each format is printed on its own line, stacked under a single shared offset. Only the first line of the group carries the offset; the rest are indented to line up beneath it.
Choosing what the bytes look like #
There are two ways to say how bytes should be rendered: a set of single-letter named shortcuts for the common cases, and the general -t / --format option for full control. You can give several at once, and they are applied in the order they appear on the command line.
Named format shortcuts #
Each of these selects one output format. Combine them freely (od -cx prints characters and hex together).
| Option | Renders each unit as |
|---|---|
-a | Named characters, ignoring the high-order bit (control characters shown by name, e.g. nul, sp, del). |
-b | Octal, one byte at a time. |
-c | Printable ASCII / UTF-8 characters, with backslash escapes (\n, \t, …) for the rest. |
-d | Unsigned decimal, 2-byte units. |
-D | Unsigned decimal, 4-byte units. |
-o | Octal, 2-byte units. |
-O | Octal, 4-byte units. |
-s | Signed decimal, 2-byte units. |
-i | Signed decimal, 4-byte units. |
-l | Signed decimal, 8-byte units. |
-I, -L | Signed decimal, 8-byte units. |
-x, -h | Hexadecimal, 2-byte units. |
-X, -H | Hexadecimal, 4-byte units. |
-f | Floating point, single precision (32-bit). |
-e, -F | Floating point, double precision (64-bit). |
Type specifications (-t, --format) #
-t takes a type specification and gives you every combination the named shortcuts cover and more. Repeat -t (or list several specs in one argument) to print multiple formats.
A type specification is a type letter, an optional size, and an optional z suffix:
| Type letter | Meaning |
|---|---|
a | Printable 7-bit ASCII, named (like -a). |
c | UTF-8 characters, with octal escapes for undefined bytes (like -c). |
d | Signed decimal. |
u | Unsigned decimal. |
o | Octal. |
x | Hexadecimal. |
f | Floating point. |
For the numeric types (d, u, o, x, f) a size says how many bytes make up one unit. It can be a number, or a letter:
| Size | Applies to | Bytes per unit |
|---|---|---|
1 / C | integer types | 1 |
2 / S | integer types | 2 |
4 / I | integer types | 4 |
8 / L | integer types | 8 |
4 / F | floating point | 4 (single precision) |
8 / D | floating point | 8 (double precision) |
16 / L | floating point | 16 (extended / long double) |
2 / H | floating point | 2 (IEEE half precision) |
2 / B | floating point | 2 (bfloat16) |
If you leave the size off, integer types default to 4 bytes and floating point defaults to 8 bytes. For the character types a and c, a size is not allowed.
Add a z at the end of a spec to append an ASCII dump — the bytes of that line shown as text, . for anything non-printable — to the right of the numbers. For example, od -t x1z gives the familiar hex-plus-text layout:
$ od -A x -t x1z file.bin
000000 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 0a >Hello, world!.<
Examples: od -t d2 is signed decimal in 2-byte units; od -t x1 is hex byte-by-byte; od -t fD is double-precision floats; od -t c is characters. od -t x1 -t c prints hex and characters as two stacked lines.
Byte order (--endian) #
For multi-byte numeric formats, --endian sets the byte order used to assemble each unit. Without it, od uses the machine's native byte order. Use --endian=big or --endian=little to force one regardless of the host.
The offset column #
| Option | Effect |
|---|---|
-A RADIX, --address-radix=RADIX | Print offsets in the given base. RADIX is one of o (octal, the default), d (decimal), x (hexadecimal), or n (none — omit the offset column entirely). |
Only the first character of the value is examined, so od -A n and od -Anone mean the same thing.
Which bytes to read #
By default od dumps the whole input. Two options narrow that to a window:
| Option | Effect |
|---|---|
-j BYTES, --skip-bytes=BYTES | Skip BYTES bytes of input before starting to dump. When several files are given, the skip runs across the concatenated stream. |
-N BYTES, --read-bytes=BYTES | Dump at most BYTES bytes, then stop. |
In both, BYTES is decimal by default, octal if it starts with 0, or hexadecimal if it starts with 0x. A suffix scales it: b = ×512, KB/K = ×1000 / ×1024, MB/M = ×1000² / ×1024², GB/G = ×1000³ / ×1024³.
Collapsing repeated lines #
When several output lines in a row would be identical, od prints the first one, then a single line containing just * to stand for the run, and resumes at the next line that differs. This keeps a dump of a large block of identical bytes (a file full of zeros, say) short.
| Option | Effect |
|---|---|
-v, --output-duplicates | Turn the collapsing off — print every line, including repeats, with no * markers. |
Output width #
| Option | Effect |
|---|---|
-w[BYTES], --width[=BYTES] | Print BYTES input bytes per output line. The default is 16; giving -w with no value uses 32. |
The width must be a whole multiple of the size of the largest format unit in play; if it isn't, od warns and rounds it to a usable value.
Finding printable strings #
| Option | Effect |
|---|---|
-S[BYTES], --strings[=BYTES] | Instead of dumping bytes, scan the input and print only runs of at least BYTES printable characters, one per line, each preceded by its offset. BYTES defaults to 3 when omitted. |
This turns od into a quick way to pull the human-readable text out of a binary file. The offset in front of each string honours -A (and is dropped entirely under -A n). -j and -N still apply, so you can restrict the scan to a window.
The traditional offset form #
od also accepts the historical calling convention, where an offset (and, in --traditional mode, a label) is given as a bare operand rather than an option:
od [named-format-options] [file] [[+]offset[.][b]]
od --traditional [options] [file] [[+]offset[.][b] [[+]label[.][b]]]
The offset says where in the input to begin, exactly like -j. It is read as octal by default, hexadecimal if prefixed with 0x, or decimal if it carries a trailing .; a trailing b multiplies it by 512. The optional leading + is allowed everywhere and is required when there is no filename (so od +20 reads standard input starting at octal offset 20).
The label (only in --traditional mode) is a second such number: the dump still begins at offset, but the offset column is displayed as if it started at label — useful for making a partial dump's offsets match the original file.
od only reads an operand as an offset in the plain form when none of -A, -j, -N, -t, -v, or -w are present. So if a real filename happens to look like a number, add a harmless option such as -j0 to force it to be treated as a filename.
A note on reading files #
od has no access rules of its own — it simply opens a file and reads its bytes. That read is subject to the same check as every read on Peios: whether you may open the file for reading is decided by the file's security descriptor (see Security descriptors). od never reveals bytes you could not already read another way; it only presents the bytes you can read in a different form.
Exit status #
| Code | Meaning |
|---|---|
0 | The input was dumped successfully. |
1 | A file could not be read, or an option, format, or offset was invalid. |
Transforming text
Peios / Using Peios / Peiosutils / Transforming text
This topic covers the commands that reshape text: putting lines in order, dropping duplicates, swapping characters, reflowing paragraphs to a width, counting what is there. Its companion, Viewing and joining text, covers printing and combining files; this topic is about changing the text itself.
The commands #
Ordering lines
| Command | Purpose |
|---|---|
sort | Sort lines — alphabetically, numerically, and many other ways. |
shuf | The opposite of sorting: put lines into a random order. |
tsort | Topological sort — order items so that each comes after the things it depends on. |
Filtering lines
| Command | Purpose |
|---|---|
uniq | Collapse or report adjacent repeated lines. |
Substituting characters
| Command | Purpose |
|---|---|
tr | Translate, squeeze, or delete individual characters. |
Whitespace
Reflowing and paginating
| Command | Purpose |
|---|---|
fmt | Reflow paragraphs to a target width. |
fold | Hard-wrap long lines at a fixed width. |
pr | Paginate and columnate text for printing. |
Indexing and counting
| Command | Purpose |
|---|---|
ptx | Produce a permuted index of the words in a file. |
wc | Count the lines, words, and bytes in a file. |
Two commands that need sorted input #
A theme worth knowing before you start: uniq only collapses adjacent duplicate lines, so duplicates scattered through a file are not caught unless the file is sorted first. sort and uniq are almost always used together — and sort -u does both jobs in one step.
Where to start #
sort is the workhorse of the topic and the one with the most depth — start there.
sort
Peios / Using Peios / Peiosutils / Transforming text
sort reads lines and writes them out in order.
sort [options] [file...]
$ sort names.txt
With no file, sort reads standard input. Given several files, it sorts all of their lines together as one stream. By default it compares lines as plain text, character by character.
How lines are compared #
The default text comparison is often not the order you want — 10 sorts before 9, and case matters. These options change the comparison:
| Option | Compares lines as… |
|---|---|
-n, --numeric-sort | numbers. 9 sorts before 10. |
-g, --general-numeric-sort | numbers, including scientific notation. Slower than -n; use it only when -n is not enough. |
-h, --human-numeric-sort | human-readable sizes — 2K before 1M before 3G. |
-M, --month-sort | month names — JAN before FEB. |
-V, --version-sort | version numbers — 1.2.10 after 1.2.9. |
-R, --random-sort | a random order (a shuffle that groups equal lines together). |
And these adjust what is compared:
| Option | Effect |
|---|---|
-f, --ignore-case | Treat lower and upper case as the same. |
-d, --dictionary-order | Consider only letters, digits, and blanks. |
-i, --ignore-nonprinting | Ignore non-printing characters. |
-b, --ignore-leading-blanks | Ignore blanks at the start of a line (or field). |
Sort order options #
| Option | Effect |
|---|---|
-r, --reverse | Reverse the result. |
-u, --unique | Output only the first line of each run of equal lines — sort and de-duplicate in one step. |
-s, --stable | Keep equal lines in their original relative order. |
Sorting by a field #
By default sort compares whole lines. -k sorts on a key — a chosen part of each line — which is what you need for tabular data.
A line is split into fields (by default at whitespace). A key is written FIELD[.CHAR][OPTIONS][,FIELD[.CHAR]] — a start field, an optional start character within it, and an optional end. The single-letter comparison options above can be appended to a key to apply only to it.
$ sort -k 2 -n scores.txt # sort on the 2nd field, numerically
$ sort -t , -k 3 people.csv # sort a CSV on the 3rd field
| Option | Effect |
|---|---|
-k, --key=KEYDEF | Sort on the key KEYDEF. May be given more than once; keys are tried in order. |
-t, --field-separator=SEP | Use SEP to split fields, instead of the whitespace default. |
Checking and merging #
| Option | Effect |
|---|---|
-c, --check | Do not sort — just check whether the input is already sorted, and report the first line that is out of order. |
-C, --check=silent | Check, but report only through the exit status. |
-m, --merge | Treat the inputs as already-sorted files and merge them, which is faster than a full sort. |
Output and resources #
| Option | Effect |
|---|---|
-o, --output=FILE | Write to FILE instead of standard output. FILE may be one of the inputs. |
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
--parallel=N | Use N threads. |
-S, --buffer-size=SIZE | Set the size of the in-memory sort buffer. |
-T, --temporary-directory=DIR | Put temporary files in DIR rather than the default location. |
--files0-from=F | Take the list of input files from F, NUL-separated. |
--debug | Underline the part of each line that was actually used as the sort key — invaluable when a -k key is not behaving. |
Exit status #
| Code | Meaning |
|---|---|
0 | The lines were sorted — or, with -c/-C, the input was already sorted. |
1 | With -c/-C, the input was not sorted. |
2 | An error — a file could not be read, or an option was invalid. |
uniq
Peios / Using Peios / Peiosutils / Transforming text
uniq deals with repeated lines: it can collapse a run of identical lines into one, count them, or print only the ones that repeated.
uniq [options] [input [output]]
$ uniq events.txt
input and output may be given as operands; with neither, uniq reads standard input and writes standard output.
uniq only sees adjacent duplicates #
This is the one thing to know about uniq: it only compares each line with the one before it. It collapses adjacent repeats. Duplicate lines scattered through a file, with other lines between them, are not caught.
So uniq is almost always paired with sort, which brings every copy of a line together first:
$ sort events.txt | uniq
If collapsing duplicates is all you need, sort -u does both jobs in one command. Use uniq itself when you want what sort -u cannot do — counting repeats, or printing only the duplicated lines.
What to output #
By default uniq prints every line, with each run of adjacent duplicates reduced to a single copy. These options change that:
| Option | Effect |
|---|---|
-c, --count | Prefix each line with the number of times it occurred. |
-d, --repeated | Print only lines that were repeated — one copy of each. |
-D | Print all copies of every repeated line, not just one. |
-u, --unique | Print only lines that were not repeated. |
--group[=WHICH] | Print every line, with runs separated by a blank line. WHICH is separate, prepend, append, or both. |
What counts as "the same" #
| Option | Effect |
|---|---|
-i, --ignore-case | Treat lines differing only in letter case as the same. |
-f, --skip-fields=N | Ignore the first N fields when comparing. |
-s, --skip-chars=N | Ignore the first N characters when comparing. |
-w, --check-chars=N | Compare at most N characters of each line. |
Other options #
| Option | Effect |
|---|---|
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read or written. |
tr
Peios / Using Peios / Peiosutils / Transforming text
tr — "translate" — works on text one character at a time. It can replace characters with other characters, remove characters, or collapse runs of a character into one.
tr [options] set1 [set2]
tr reads standard input and writes standard output — it has no file arguments, so it always sits in a pipeline.
$ tr 'a-z' 'A-Z' < notes.txt # lower-case to upper-case
$ tr -d ' ' < spaced.txt # delete every space
$ tr -s ' ' < padded.txt # collapse runs of spaces to one
The three jobs #
What tr does depends on the options and how many sets you give it.
- Translate — give two sets. Each character in
set1is replaced by the character at the same position inset2.tr 'abc' 'xyz'turns everyaintox,bintoy,cintoz. - Delete (
-d) — give one set. Every character inset1is removed. - Squeeze (
-s) — each run of a repeated character that appears in the relevant set is collapsed to a single occurrence.
-d and -s can be combined: delete one set of characters, then squeeze another.
Writing a set #
A set is a string of characters, with a few shorthands:
| Notation | Means |
|---|---|
a-z | A range — every character from a to z. |
[:alpha:], [:digit:], [:space:], … | A named character class. |
[=c=] | An equivalence class — every character that sorts the same as c. |
[c*n] | The character c repeated n times — for padding a set to a length. |
\n, \t, \\, \NNN | Escapes — newline, tab, backslash, an octal byte. |
Options #
| Option | Effect |
|---|---|
-d, --delete | Delete the characters in set1 instead of translating. |
-s, --squeeze-repeats | Collapse each run of a repeated character listed in the last set into one. |
-c, -C, --complement | Operate on the complement of set1 — every character it does not list. |
-t, --truncate-set1 | Before translating, shorten set1 to the length of set2, rather than reusing set2's last character to cover the surplus. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A usage error — the wrong number of sets, or a malformed set. |
expand
Peios / Using Peios / Peiosutils / Transforming text
expand converts tab characters into spaces, so that text lines up the same way no matter what tab width something is viewed with.
expand [options] [file...]
$ expand source.txt > source-spaces.txt
A tab is not a fixed number of spaces — it jumps to the next tab stop, and where those stops are is a display setting. expand removes the ambiguity: it replaces each tab with exactly the number of spaces needed to reach the same column, baking the layout in. With no file, it reads standard input.
unexpand is the reverse operation.
Options #
| Option | Effect |
|---|---|
-t, --tabs=N | Set tab stops every N columns instead of the default 8. |
-t, --tabs=LIST | Given a comma-separated list, place tab stops at those explicit column positions. |
-i, --initial | Convert only the tabs that come before the first non-blank character on each line — leave tabs within the text alone. Useful for re-indenting code without disturbing aligned tabs inside it. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or a tab specification was invalid. |
unexpand
Peios / Using Peios / Peiosutils / Transforming text
unexpand is the reverse of expand: it converts runs of spaces back into tab characters.
unexpand [options] [file...]
$ unexpand spaced.txt > tabbed.txt
Where a run of spaces reaches a tab stop, unexpand replaces it with a tab. By default it only converts the leading spaces on each line — the indentation — and leaves spacing within the text alone. With no file, it reads standard input.
Options #
| Option | Effect |
|---|---|
-t, --tabs=N | Set tab stops every N columns instead of the default 8. A comma-separated list places stops at explicit column positions. Using -t also enables -a. |
-a, --all | Convert all runs of spaces that reach a tab stop, not only the leading indentation. |
--first-only | Convert only the leading run of blanks on each line. Overrides -a. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or a tab specification was invalid. |
fmt
Peios / Using Peios / Peiosutils / Transforming text
fmt reflows paragraphs: it rejoins the lines of a paragraph and re-breaks them so each comes out close to a target width. Words move freely between lines to make the result fit.
fmt [options] [file...]
$ fmt -w 72 draft.txt
fmt works on paragraphs — runs of non-blank lines separated by blank lines — and treats word boundaries as places it may break. The result reads naturally. This is what distinguishes it from fold, which simply cuts every line at a fixed column without regard to words.
With no file, fmt reads standard input.
Setting the width #
| Option | Effect |
|---|---|
-w, --width=WIDTH | Fill lines up to at most WIDTH columns. The default is 75. |
-g, --goal=WIDTH | Aim for WIDTH columns — the width fmt targets, while -w is the hard maximum. Defaults to about 93% of the maximum. |
Controlling the reflow #
| Option | Effect |
|---|---|
-s, --split-only | Only split lines that are too long; never join short lines together. |
-u, --uniform-spacing | Put exactly one space between words and two between sentences. |
-c, --crown-margin | Preserve the indentation of a paragraph's first two lines; indent the rest to match the second. |
-t, --tagged-paragraph | Like -c, but the first line must be indented differently from the second, or the two are treated as separate paragraphs. |
-q, --quick | Break lines faster, accepting a more ragged right edge. |
Selecting which lines to format #
| Option | Effect |
|---|---|
-p, --prefix=PREFIX | Reformat only lines that begin with PREFIX; reattach PREFIX afterwards. Useful for reflowing comment blocks in source code. |
-m, --preserve-headers | Detect and preserve mail-style header lines rather than reflowing them. |
--tab-width=N | Treat a tab as N columns when measuring line length. Tabs are kept in the output; this affects measurement only. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or an option value was invalid. |
fold
Peios / Using Peios / Peiosutils / Transforming text
fold breaks long lines so that none exceeds a fixed width. Every line longer than the limit is cut into pieces.
fold [options] [file...]
$ fold -w 80 wide.txt
By default fold wraps at 80 columns, and it cuts at exactly that column — even in the middle of a word. That hard cut is the difference between fold and fmt: fmt reflows paragraphs and breaks between words; fold just enforces a width, mechanically. Use fold when you need a guarantee that no line is wider than N; use fmt when you want the result to read well.
With no file, fold reads standard input.
Options #
| Option | Effect |
|---|---|
-w, --width=WIDTH | Wrap at WIDTH columns instead of 80. |
-s, --spaces | Break at a space within the width where one exists, rather than mid-word — a gentler wrap. |
-b, --bytes | Count bytes rather than display columns. Control characters such as tab and newline then count as ordinary bytes. |
-c, --characters | Count character positions rather than display columns. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or the width was invalid. |
pr
Peios / Using Peios / Peiosutils / Transforming text
pr prepares text for printing on paper. It breaks a file into pages, puts a header and a trailer on each, and can arrange the text into columns.
pr [options] [file...]
$ pr report.txt
Run plainly, pr divides the input into 66-line pages, each beginning with a five-line header — the date, the file name, and a page number — and ending with a trailing margin. With no file, it reads standard input.
Columns #
pr can lay text out in several columns per page.
| Option | Effect |
|---|---|
-COLUMN | Produce COLUMN columns per page (e.g. -3 for three). Text fills down the first column, then the next. |
-a, --across | Fill the columns across — line 1 in column 1, line 2 in column 2, and so on — rather than down. |
-m, --merge | Print several files side by side, one per column. |
-w, --width=WIDTH | Set the page width, for multi-column output. |
The header and the page #
| Option | Effect |
|---|---|
-h, --header=TEXT | Use TEXT in the header instead of the file name. |
-t, --omit-header | Print neither the header nor the trailer. |
-T, --omit-pagination | Drop the header, the trailer, and all pagination — just the text. |
-l, --length=LINES | Set the page length to LINES instead of 66. |
-o, --indent=N | Indent every line by N spaces. |
-D, --date-format=FORMAT | Format the header's date with FORMAT. |
-F, -f, --form-feed | Separate pages with a form-feed character rather than blank lines. |
Selecting and numbering #
| Option | Effect |
|---|---|
--pages=FIRST[:LAST] | Print only pages FIRST through LAST. |
-n, --number-lines[=SEP[WIDTH]] | Number every line. |
-N, --first-line-number=N | Begin line numbering at N. |
-d, --double-space | Double-space the output. |
Other options #
| Option | Effect |
|---|---|
-s, --separator-char[=CHAR] | Separate columns with a single CHAR (default tab) rather than padding with spaces. |
-S, --sep-string[=STRING] | Separate columns with STRING. |
-e, --expand-tabs[=CHAR[WIDTH]] | Expand input tabs to spaces. |
-J, --join-lines | Merge full lines, turning off width truncation. |
-r, --no-file-warnings | Do not warn when a file cannot be opened. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or an option was invalid. |
ptx
Peios / Using Peios / Peiosutils / Transforming text
ptx produces a permuted index — a concordance. For each significant word in the input, it prints a line showing that word together with the text around it, so the same passage appears once per keyword it contains.
ptx [options] [input...]
$ ptx manual.txt
A permuted index is the kind of index found at the back of a reference book: every keyword, in alphabetical order, each shown in its context. ptx builds one from plain text. With no file, it reads standard input.
Choosing the keywords #
| Option | Effect |
|---|---|
-W, --word-regexp=REGEXP | Treat anything matching REGEXP as a keyword. |
-b, --break-file=FILE | Take the set of word-break characters from FILE. |
-i, --ignore-file=FILE | Read a list of words to exclude from FILE. |
-o, --only-file=FILE | Index only the words listed in FILE. |
-f, --ignore-case | Fold case together when sorting. |
Output format #
| Option | Effect |
|---|---|
-w, --width=N | Set the output width, in columns. |
-g, --gap-size=N | Set the gap, in columns, between the output fields. |
-A, --auto-reference | Generate a reference (file name and line number) for each entry automatically. |
-r, --references | Treat the first field of each input line as a reference for that line. |
-R, --right-side-refs | Put references on the right, and exclude them from the width count. |
-F, --flag-truncation=STRING | Use STRING to mark where a line was truncated. |
-O, --format=roff | Emit the index as roff typesetting directives. |
-T, --format=tex | Emit the index as TeX typesetting directives. |
-M, --macro-name=NAME | Use NAME as the macro name in roff/TeX output. |
-G, --traditional | Behave like the older System V ptx, without the extended features. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or an option value was invalid. |
tsort
Peios / Using Peios / Peiosutils / Transforming text
tsort performs a topological sort. Given a set of "this must come before that" rules, it produces an order in which everything appears after the things it depends on.
tsort [file]
$ tsort dependencies.txt
With no file, tsort reads standard input.
The input #
tsort reads its input as a flat sequence of whitespace-separated tokens, taken in pairs. Each pair A B means "A must come before B" — an edge in a dependency graph.
compiler linker
linker installer
compiler installer
That input says the linker depends on the compiler, the installer on the linker, and the installer on the compiler. tsort reads the pairs and prints the items in a valid order:
compiler
linker
installer
A token paired with itself (A A) simply introduces A with no dependency — a way to list an item that nothing else mentions.
Cycles #
A topological order only exists if the dependencies form no cycle. If A must come before B and B must come before A, there is no valid order. tsort detects this, reports the loop it found, and exits with a failure status — it still prints an order, but the cycle means that order cannot satisfy every rule.
Where it is used #
tsort answers "what order do I do these in?" — building components in dependency order, scheduling tasks, sequencing anything where some steps must precede others. It is the dependency-aware counterpart to sort, which orders by value rather than by dependency.
Exit status #
| Code | Meaning |
|---|---|
0 | A valid order was produced. |
1 | The input contained a cycle, had an odd number of tokens, or could not be read. |
wc
Peios / Using Peios / Peiosutils / Transforming text
wc — "word count" — counts what is in a file: its lines, its words, and its bytes.
wc [options] [file...]
$ wc report.txt
214 1832 12044 report.txt
By default wc prints three numbers per file — lines, words, bytes — followed by the file name. Given several files, it adds a total line. With no file, it reads standard input.
Choosing what to count #
Pass any of these and wc prints only the counts you ask for, in the fixed order lines, words, characters/bytes, longest-line:
| Option | Counts |
|---|---|
-l, --lines | The number of lines. |
-w, --words | The number of words — runs of non-whitespace. |
-c, --bytes | The number of bytes. |
-m, --chars | The number of characters. This differs from -c for multi-byte text, where one character is several bytes. |
-L, --max-line-length | The length of the longest line. |
$ wc -l report.txt # just the line count
214 report.txt
Other options #
| Option | Effect |
|---|---|
--files0-from=F | Take the list of files to count from F, as NUL-terminated names. - reads the list from standard input. |
--total=WHEN | Control the total line: auto (the default — shown for more than one file), always, only (just the total, no per-file lines), or never. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was counted. |
1 | A file could not be read. |
shuf
Peios / Using Peios / Peiosutils / Transforming text
shuf shuffles: it outputs its input lines in a random order. Every possible ordering is equally likely.
shuf [options] [file]
shuf -e [options] arg...
shuf -i lo-hi [options]
$ shuf playlist.txt
shuf is the opposite of sort — instead of imposing an order, it removes one. With no file, it reads standard input.
Where the lines come from #
shuf has three ways of getting its input, one per usage form:
| Option | Input is… |
|---|---|
| (a file, or stdin) | the lines of the file. |
-e, --echo | the command-line arguments, each treated as one line. |
-i, --input-range=LO-HI | the integers from LO to HI, each treated as one line. |
$ shuf -e red green blue # shuffle three given words
$ shuf -i 1-100 # the numbers 1..100 in random order
Shaping the output #
| Option | Effect |
|---|---|
-n, --head-count=COUNT | Output at most COUNT lines — a random sample rather than the whole shuffle. |
-r, --repeat | Allow lines to be repeated, so output can be longer than the input. Pair with -n to set how many. |
-o, --output=FILE | Write to FILE instead of standard output. |
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
shuf -n 1 is a common idiom — pick one random line.
Reproducible shuffles #
A shuffle is random by default. To get the same shuffle every time — for a repeatable test, say — fix the randomness:
| Option | Effect |
|---|---|
--random-seed=STRING | Seed the shuffle with STRING. The same seed gives the same shuffle. |
--random-source=FILE | Take the random bytes from FILE. The same file gives the same shuffle. Cannot be combined with --random-seed. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A failure — the input could not be read, or -r was used with no lines to repeat. |
Output and evaluation
Peios / Using Peios / Peiosutils / Output and evaluation
This topic gathers the small, sharp commands that scripts are built from: the ones that print something, that read or set the environment, that evaluate an expression or a condition, and that simply succeed or fail.
The commands #
Producing text
| Command | Purpose |
|---|---|
echo | Print its arguments as a line of text. |
printf | Print text from a format string, with precise control over layout. |
yes | Print a line over and over, without stopping. |
seq | Print a sequence of numbers. |
tee | Copy standard input to standard output and to each named file. |
The environment
| Command | Purpose |
|---|---|
env | Run a command with a modified environment — or print the current one. |
printenv | Print environment variables. |
Numbers
| Command | Purpose |
|---|---|
numfmt | Convert numbers to and from human-readable forms (1.5G, 1000000). |
factor | Print the prime factors of a number. |
Evaluating
| Command | Purpose |
|---|---|
expr | Evaluate an arithmetic, string, or comparison expression. |
test | Evaluate a condition — a file check or a comparison — as a true/false result. |
Exit status
| Command | Purpose |
|---|---|
true and false | Do nothing, and succeed — or do nothing, and fail. |
A note on exit status #
Several commands here exist to be used for their exit status rather than their output. Every command, when it finishes, leaves behind a number: 0 means success, anything else means a kind of failure. A shell's if, &&, and || all act on that number.
test is the clearest case — it prints nothing and exists only to set an exit status from a condition. true and false are the most minimal: fixed success and fixed failure. Where this topic's pages give an exit-status table, that number is often the whole point of the command.
Where to start #
For everyday printing, echo; for anything that needs columns, padding, or precise formatting, printf.
echo
Peios / Using Peios / Peiosutils / Output and evaluation
echo prints its arguments, separated by single spaces, followed by a newline.
echo [options] [string...]
$ echo Hello, Peios
Hello, Peios
It is the simplest way to put a line of text on standard output — printing a message, or feeding a fixed string into a pipe.
Options #
| Option | Effect |
|---|---|
-n | Do not print the trailing newline. |
-e | Interpret backslash escape sequences in the arguments (see below). |
-E | Do not interpret escape sequences. This is the default. |
Escape sequences #
With -e, echo recognises these backslash sequences and prints the character they stand for:
| Sequence | Character |
|---|---|
\\ | A literal backslash. |
\n | Newline. |
\t | Horizontal tab. |
\r | Carriage return. |
\b | Backspace. |
\f | Form feed. |
\v | Vertical tab. |
\a | Alert (the terminal bell). |
\e | Escape. |
\0NNN | The byte with octal value NNN. |
\xHH | The byte with hexadecimal value HH. |
\c | Stop — produce no further output. |
echo and printf #
echo is fixed: arguments, spaces, a newline. The moment you need control over the output — columns, padding, a number formatted a particular way, output with no spaces between pieces — use printf instead. printf is also more predictable across environments, since echo's handling of escapes and -n varies.
A note on shells #
Many command shells provide their own built-in echo, and the shell's version is what runs when you type echo at a prompt. Built-in versions vary — especially in how they treat -n, -e, and escape sequences — so their behaviour may differ from what is described here. To be certain you are running this command rather than the shell built-in, invoke it by its full path.
Exit status #
| Code | Meaning |
|---|---|
0 | The output was written. |
1 | The output could not be written. |
printf
Peios / Using Peios / Peiosutils / Output and evaluation
printf prints text built from a format string. The format string is printed literally, except that escape sequences become characters and substitution fields are replaced by the arguments that follow.
printf format [argument...]
$ printf 'the letter %X comes before %X\n' 10 11
the letter A comes before B
Where echo prints arguments as-is, printf gives you control: padding, column widths, number bases, fixed decimal places. It is the command for output that has to line up or be exact.
printf writes only what the format string says — there is no automatic trailing newline. Include \n yourself where you want one.
Escape sequences #
The format string interprets these backslash sequences:
| Sequence | Character |
|---|---|
\\ | Backslash. |
\n \t \r | Newline, tab, carriage return. |
\b \f \v \a | Backspace, form feed, vertical tab, alert. |
\e | Escape. |
\NNN | The byte with octal value NNN. |
\xHH | The byte with hexadecimal value HH. |
\uHHHH, \UHHHHHHHH | A Unicode character by hexadecimal code point. |
%% | A literal %. |
Substitution fields #
A field begins with % and is replaced by the next argument, formatted accordingly.
| Field | Formats the argument as… |
|---|---|
%s | a string. |
%b | a string, with backslash escapes in it interpreted. |
%q | a string, quoted so it is safe to reuse as shell input. |
%c | a single character. |
%d, %i | a signed integer. |
%u | an unsigned integer. |
%x, %X | an unsigned integer in hexadecimal (lower- or upper-case). |
%o | an unsigned integer in octal. |
%f, %F | a decimal floating-point number. |
%e, %E | a number in scientific notation. |
%g, %G | whichever of decimal or scientific notation is shorter. |
Width and precision #
Between the % and the field letter you may put two numbers — %[width][.precision]field:
- width — the minimum number of columns. Output narrower than this is padded with leading spaces; a negative width pads on the right instead.
- precision — after a
.; its meaning depends on the field. For a string it is a maximum length; for an integer, a minimum digit count (zero-padded); for a float, the number of decimal places.
$ printf '%-10s %5.2f\n' apples 3.1
apples 3.10
Reusing the format string #
If more arguments are supplied than the format string has fields, printf repeats the format string until the arguments run out. If there are too few, the leftover fields default to an empty string (or 0 for a number).
$ printf '%s: %d\n' alpha 1 beta 2 gamma 3
alpha: 1
beta: 2
gamma: 3
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A missing operand, a bad format string, or a write failure. |
yes
Peios / Using Peios / Peiosutils / Output and evaluation
yes prints a line over and over, without stopping.
yes [string...]
$ yes
y
y
y
...
With no argument it prints y forever. Given arguments, it prints them — joined by spaces — as the repeated line instead.
$ yes retry
retry
retry
...
yes never stops on its own. It ends when something downstream closes the pipe, or when you interrupt it with Ctrl-C.
What it is for #
yes exists to answer prompts. An older interactive command that asks "are you sure? [y/n]" again and again can be fed a stream of confirmations:
$ yes | slow-interactive-command
Most modern commands have a proper option for this — rm -f, and similar — and that is the better way when it exists. yes is the fallback for a command that offers no such option. It is also a quick way to generate an endless stream of identical lines for a test.
Exit status #
| Code | Meaning |
|---|---|
0 | The output was closed normally. |
1 | A write error occurred. |
seq
Peios / Using Peios / Peiosutils / Output and evaluation
seq prints a sequence of numbers, one per line.
seq [options] last
seq [options] first last
seq [options] first increment last
$ seq 3
1
2
3
$ seq 2 2 10
2
4
6
8
10
The three forms control where the sequence starts and how it steps:
seq last— count from 1 up tolast.seq first last— count fromfirstup tolast.seq first increment last— count fromfirsttolast, stepping byincrement.
The increment may be negative, to count down, and the numbers may be fractional. seq is most often used to drive a loop a fixed number of times.
Options #
| Option | Effect |
|---|---|
-s, --separator=STRING | Separate the numbers with STRING instead of a newline. |
-t, --terminator=STRING | End the output with STRING instead of a newline. |
-w, --equal-width | Pad the numbers with leading zeros so they all have the same width. |
-f, --format=FORMAT | Format each number with a printf-style floating-point FORMAT. |
$ seq -s , 1 5
1,2,3,4,5
$ seq -w 8 10
08
09
10
-f takes a single floating-point conversion — see printf for the format syntax — and cannot be combined with -w.
Exit status #
| Code | Meaning |
|---|---|
0 | The sequence was printed. |
1 | An argument was not a valid number, the increment was zero, or no argument was given. |
env
Peios / Using Peios / Peiosutils / Output and evaluation
env runs a command with a changed environment — the set of NAME=VALUE variables a process inherits. Run with no command, it prints the current environment instead.
env [options] [NAME=VALUE]... [command [arg]...]
$ env LANG=C TZ=UTC my-program
$ env
PATH=/bin
HOME=/home/jack
...
How it works #
You give env a list of NAME=VALUE assignments, then a command. env applies the assignments to its own environment and then runs the command, which inherits the result. The assignments affect only that one run — your shell's environment is untouched.
This is the clean way to run something with a particular variable set, without changing it for everything else, and the standard way to run a command with one variable temporarily different.
With no command, env simply prints the environment it would have used — which, with no options, is the current one.
Building the environment #
| Option | Effect |
|---|---|
-i, --ignore-environment | Start from an empty environment, not the inherited one — so the command sees only what you assign. A bare - argument means the same. |
-u, --unset=NAME | Remove NAME from the environment. |
--file=FILE | Read NAME=VALUE lines from a .env-style FILE and apply them. |
The order is: start (inherited, or empty with -i), apply --file, apply --unset, apply the NAME=VALUE arguments.
Running the command #
| Option | Effect |
|---|---|
-C, --chdir=DIR | Change to DIR before running the command. |
--argv0=NAME | Run the command but present NAME as its zeroth argument. |
-S, --split-string=S | Split S into separate arguments. This exists for script interpreter lines, where everything after the interpreter arrives as one string. |
Signal handling #
env can adjust how the command treats signals before launching it:
| Option | Effect |
|---|---|
--ignore-signal[=SIG] | Set the named signals to be ignored. |
--default-signal[=SIG] | Reset the named signals to their default handling. |
--block-signal[=SIG] | Block delivery of the named signals while the command runs. |
--list-signal-handling | List the signal-handling changes the other options requested. |
Other options #
| Option | Effect |
|---|---|
-0, --null | When printing the environment, end each line with a NUL character instead of a newline. Not valid together with a command. |
-v, --debug | Print each processing step env performs. |
Exit status #
When env runs a command, it exits with that command's status. The exception is a failure in env itself:
| Code | Meaning |
|---|---|
| (command's own) | The command ran; this is its exit status. |
125 | env itself failed — for example, a bad option. |
126 | The command was found but could not be run. |
127 | The command was not found. |
printenv
Peios / Using Peios / Peiosutils / Output and evaluation
printenv prints environment variables.
printenv [options] [variable...]
Name one or more variables and printenv prints the value of each. Name none and it prints the whole environment, one NAME=VALUE pair per line.
$ printenv HOME
/home/jack
$ printenv
PATH=/bin
HOME=/home/jack
...
Options #
| Option | Effect |
|---|---|
-0, --null | End each output line with a NUL character instead of a newline — so values that themselves contain newlines can still be told apart. |
printenv and env #
Both can print the environment. printenv is the one built for reading it — it can pick out a single variable by name. env prints the environment too, but its real job is running a command with a modified one. Use printenv to look something up; use env to change something for a command.
Exit status #
| Code | Meaning |
|---|---|
0 | Every named variable was found and printed. |
1 | A named variable was not set. |
2 | A usage error. |
numfmt
Peios / Using Peios / Peiosutils / Output and evaluation
numfmt converts numbers between plain digits and human-readable forms — turning 1500000 into 1.5M, or 1.5M back into 1500000.
numfmt [options] [number...]
$ numfmt --to=si 1500000
1.5M
$ numfmt --from=si 1.5M
1500000
numfmt takes numbers as arguments, or — with none — reads them from standard input, which lets it rescale a column of numbers flowing through a pipe.
The direction of conversion #
| Option | Effect |
|---|---|
--from=UNIT | Parse input numbers that carry a UNIT suffix, scaling them up to plain digits. |
--to=UNIT | Scale output numbers down and add a UNIT suffix. |
A UNIT is one of:
| Unit | Suffixes mean |
|---|---|
none | No suffixes — a suffix is an error. The default. |
si | Powers of 1000: 1K = 1000, 1M = 1000000. |
iec | Powers of 1024: 1K = 1024, 1M = 1048576. |
iec-i | Powers of 1024 with a two-letter suffix: 1Ki = 1024, 1Mi = 1048576. |
auto | Accept any of the above on input, reading Ki/Mi as powers of 1024 and bare K/M as powers of 1000. |
Working on fields #
By default numfmt converts the whole input line. To convert numbers sitting in a column of wider text, name the field:
| Option | Effect |
|---|---|
--field=FIELDS | Convert only the numbers in these fields. FIELDS uses the same range syntax as cut — 3, 2-5, 2-. |
-d, --delimiter=X | Use X to separate fields instead of whitespace. |
--header[=N] | Pass the first N lines through unconverted, as a header. |
Shaping the output #
| Option | Effect |
|---|---|
--format=FORMAT | Format the number with a printf-style floating-point FORMAT, controlling width, padding, and precision. |
--padding=N | Pad the output to N columns — positive right-aligns, negative left-aligns. |
--grouping | Group digits according to the locale (for example, 1,000,000). |
--round=METHOD | Choose the rounding method used when scaling. |
--suffix=SUFFIX | Append SUFFIX to each result, and accept it on input. |
--from-unit=N / --to-unit=N | Set the unit size the input or output is counted in. |
Other options #
| Option | Effect |
|---|---|
--invalid=MODE | Choose what to do with input that is not a valid number: abort, fail, warn, or ignore. |
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
--debug | Print warnings about questionable input. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every number was converted. |
2 | An input was not a valid number, or an option was misused. |
expr
Peios / Using Peios / Peiosutils / Output and evaluation
expr evaluates a single expression and prints its value.
expr expression
$ expr 6 + 7
13
$ expr length "Peios"
5
Each part of the expression — every number, operator, and keyword — is a separate argument. That is why the spaces matter: expr 6+7 is one argument and not an expression, while expr 6 + 7 is three.
Arithmetic #
| Operator | Result |
|---|---|
ARG1 + ARG2 | Sum. |
ARG1 - ARG2 | Difference. |
ARG1 * ARG2 | Product. |
ARG1 / ARG2 | Integer quotient. |
ARG1 % ARG2 | Remainder. |
Comparison #
A comparison yields 1 for true and 0 for false. It is arithmetic when both sides are numbers, and lexical otherwise.
| Operator | True when… |
|---|---|
ARG1 = ARG2 | the two are equal. |
ARG1 != ARG2 | they are unequal. |
ARG1 < ARG2 | ARG1 is less. |
ARG1 <= ARG2 | ARG1 is less or equal. |
ARG1 > ARG2 | ARG1 is greater. |
ARG1 >= ARG2 | ARG1 is greater or equal. |
Logic #
| Operator | Result |
|---|---|
ARG1 | ARG2 | ARG1 if it is neither null nor 0; otherwise ARG2. |
ARG1 & ARG2 | ARG1 if neither argument is null or 0; otherwise 0. |
String operations #
| Form | Result |
|---|---|
length STRING | The number of characters in STRING. |
substr STRING POS LENGTH | LENGTH characters of STRING, counting POS from 1. |
index STRING CHARS | The position of the first of any of CHARS in STRING, or 0. |
STRING : REGEXP | Match REGEXP anchored at the start of STRING. Returns the matched length, or the text captured by \(…\). |
match STRING REGEXP | The same as STRING : REGEXP. |
Many of these operators — *, <, |, ( — are also meaningful to a shell, so quote or escape them so they reach expr intact.
Exit status #
expr's exit status reports on the value it computed:
| Code | Meaning |
|---|---|
0 | The result was neither null nor 0. |
1 | The result was null or 0. |
2 | The expression was syntactically invalid. |
3 | An error occurred during evaluation — such as division by zero. |
factor
Peios / Using Peios / Peiosutils / Output and evaluation
factor prints the prime factors of a number.
factor [options] [number...]
$ factor 360
360: 2 2 2 3 3 5
Each line of output is the original number, a colon, and its prime factors in increasing order, repeated as often as they divide in. Give several numbers and factor factors each; give none and it reads numbers from standard input.
Options #
| Option | Effect |
|---|---|
-h, --exponents | Write a repeated factor in exponent form — p^e — instead of repeating it. |
$ factor -h 360
360: 2^3 3^2 5
Exit status #
| Code | Meaning |
|---|---|
0 | Every number was factored. |
1 | An input was not a valid positive integer. |
test
Peios / Using Peios / Peiosutils / Output and evaluation
test evaluates a single condition and reports whether it is true or false. It prints nothing — the answer is its exit status: 0 for true, 1 for false. That makes it the command a shell's if, while, &&, and || are built on.
test expression
[ expression ]
The two forms are the same command. [ is test under another name; when invoked as [, it requires a closing ] as its last argument. [ -f notes.txt ] and test -f notes.txt are identical.
$ test -f notes.txt && echo "the file exists"
File tests #
Existence and type #
| Test | True when the file… |
|---|---|
-e FILE | exists. |
-f FILE | exists and is a regular file. |
-d FILE | exists and is a directory. |
-L FILE, -h FILE | exists and is a symbolic link. |
-p FILE | exists and is a named pipe (FIFO). |
-S FILE | exists and is a socket. |
-b FILE | exists and is a block device. |
-c FILE | exists and is a character device. |
-s FILE | exists and is not empty. |
Access #
These three tests ask whether you may actually use the file:
| Test | True when… |
|---|---|
-r FILE | you may read the file. |
-w FILE | you may write the file. |
-x FILE | you may execute the file, or search the directory. |
On Peios these are real access checks. test asks the kernel whether your token is granted the right in question — a live check against the file's security descriptor, the same decision any actual read, write, or execute would face. The answer is therefore honest: test -w FILE is true exactly when a write would be permitted.
-x on a regular file means "you are permitted to execute it" — which is a different question from whether the file is an executable. A file can be runnable code and still fail -x for you, and the two are decided separately; see Access decisions.
Comparing two files #
| Test | True when… |
|---|---|
FILE1 -nt FILE2 | FILE1 is newer than FILE2. |
FILE1 -ot FILE2 | FILE1 is older than FILE2. |
FILE1 -ef FILE2 | both name the same file (same device and inode). |
Other file tests #
| Test | True when the file… |
|---|---|
-t FD | file descriptor FD is open on a terminal. |
-N FILE | has been modified since it was last read. |
-O FILE, -G FILE | carries an owner-id / group-id field matching the caller's. |
-g FILE, -u FILE, -k FILE | has its set-group-id, set-user-id, or sticky inode bit set. |
The last two rows probe decorative inode fields. The Peios access model does not consult them — they are stored on the inode and reported for completeness, but they carry no authority. For a true picture of what may be done to a file, use the access tests -r, -w, -x above, not -O, -g, or -u.
String tests #
| Test | True when… |
|---|---|
-n STRING | STRING is not empty. A bare STRING means the same. |
-z STRING | STRING is empty. |
STRING1 = STRING2 | the strings are equal. |
STRING1 != STRING2 | the strings are unequal. |
STRING1 < STRING2 | STRING1 sorts before STRING2. |
STRING1 > STRING2 | STRING1 sorts after STRING2. |
Integer comparisons #
| Test | True when… |
|---|---|
A -eq B | A equals B. |
A -ne B | A does not equal B. |
A -lt B | A is less than B. |
A -le B | A is less than or equal to B. |
A -gt B | A is greater than B. |
A -ge B | A is greater than or equal to B. |
Combining conditions #
| Form | Result |
|---|---|
! EXPRESSION | The negation. |
( EXPRESSION ) | Grouping — escape the parentheses so the shell does not take them. |
EXPR1 -a EXPR2 | True when both are true. |
EXPR1 -o EXPR2 | True when either is true. |
-a and -o are genuinely ambiguous to parse and are best avoided. Combine separate test calls with the shell's own && and || instead:
test -f notes.txt && test -r notes.txt
A note on shells #
Many command shells provide their own built-in test and [, and the shell's version is what runs when you type test at a prompt or in a script. A built-in may not behave as described here — in particular, the access tests -r, -w, and -x are the real access checks described above only when this command runs. To be certain you are running this command rather than the shell built-in, invoke it by its full path.
Exit status #
| Code | Meaning |
|---|---|
0 | The expression was true. |
1 | The expression was false. |
2 | The expression was malformed. |
true and false
Peios / Using Peios / Peiosutils / Output and evaluation
true and false do nothing at all. Their only product is an exit status: true always succeeds, false always fails.
true
false
$ true ; echo $?
0
$ false ; echo $?
1
true exits 0. false exits 1. They take no arguments that matter, produce no output, and have no effect on anything.
What they are for #
A command that is only an exit status is useful as a fixed value in places that expect a command:
- An always-true or always-false loop.
while trueis the standard way to write a loop that runs until something inside it breaks out. - A deliberate placeholder. Setting a configurable command to
truemakes that step do nothing and "succeed";falsemakes it a step that always fails. - A known result in a test. When a script or a condition needs a guaranteed pass or fail to check against,
trueandfalseprovide it.
They are the simplest commands there are — and the overview's note on exit status is the whole idea behind them.
Exit status #
| Code | Meaning |
|---|---|
0 | Returned by true, always. |
1 | Returned by false, always. |
tee
Peios / Using Peios / Peiosutils / Output and evaluation
tee reads standard input and writes it, unchanged, to standard output and to each file you name. It is the T-junction of a pipeline: the data keeps flowing down the pipe while a copy is also captured on disk.
tee [options] [file...]
$ echo saved | tee note.txt
saved
$ cat note.txt
saved
The name is the shape of the letter T — one stream in, the same stream out two ways. That makes tee the tool for the moment you want to both see (or keep piping) some data and keep it at the same time.
Writing to several places at once #
Every byte that arrives on standard input is written to standard output and to each named file. Name more than one file and each one receives a full, identical copy:
$ producer | tee a.log b.log c.log | consumer
Here producer's output reaches consumer down the pipe exactly as if tee were not there, and a.log, b.log, and c.log each end up holding the same complete copy of it. With no files named, tee copies input straight to standard output and nothing else — a plain pass-through.
tee does not buffer between reads: it writes each chunk out as it is read, so a file being written by tee fills up as the data flows, rather than all at once when the input ends.
Overwrite or append #
By default, each named file is truncated — opened fresh, so any existing contents are discarded before the new data is written. A file that does not exist is created.
With -a (--append), tee appends to each named file instead: existing contents are kept and the new data is added to the end. A file that does not exist is still created.
$ echo first | tee log.txt # log.txt now holds "first"
$ echo second | tee -a log.txt # log.txt now holds "first" then "second"
Creating a file inherits a security descriptor #
When tee creates a new output file — a named file that did not already exist — that file needs a security descriptor, and it inherits one from the directory it is created in. This is the same creation-inherits-a-descriptor rule that governs every file-creating tool on Peios; see Files and directories for the shared model.
With -a, when the named file already exists, tee opens that existing file to append and creates nothing, so no new descriptor is involved. Whether opening a file to write succeeds — whether creating a new one or writing an existing one — is decided by the normal access check against the relevant directory or file. tee reports a file it cannot open and, by default, carries on with the outputs it can write.
Files named - #
tee gives no special meaning to -. A file argument of - refers to a file literally named - in the current directory; it is opened, created, and written exactly like any other named file. It is not a stand-in for standard output — standard output is always written and needs no naming.
Ignoring interrupts #
With -i (--ignore-interrupts), tee ignores the interrupt signal (the one a terminal sends on Ctrl-C). This lets tee keep copying to its files even as an interrupt tears down the rest of a pipeline, so the capture is not cut short.
Behaviour on write errors #
By default, if a write to one output fails, tee reports it and stops writing to that output, but keeps writing to the others; a broken-pipe error on standard output is treated quietly. Two options change this policy.
-p sets write-error behaviour so that errors on a pipe (a downstream reader that has gone away) are ignored while other write errors are still reported.
--output-error[=MODE] sets the policy explicitly. Given on its own, with no =MODE, it selects warn-nopipe. The modes are:
| Mode | Behaviour |
|---|---|
warn | Warn on a write error to any output, including a broken pipe, and continue with the remaining outputs. |
warn-nopipe | Warn on a write error that is not a pipe error, and continue. Broken-pipe errors are ignored. This is the mode selected by a bare --output-error. |
exit | Exit on a write error to any output, including a broken pipe. |
exit-nopipe | Exit on a write error that is not a pipe error. Broken-pipe errors are ignored. |
Options #
| Option | Effect |
|---|---|
-a, --append | Append to each named file rather than truncating it. |
-i, --ignore-interrupts | Ignore the interrupt signal. |
-p | Ignore errors from writing to a broken pipe, still reporting other write errors. |
--output-error[=MODE] | Set the write-error policy: warn, warn-nopipe, exit, or exit-nopipe. A bare --output-error means warn-nopipe. |
Exit status #
| Code | Meaning |
|---|---|
0 | The input was copied to standard output and every named file without error. |
1 | A file could not be opened, a write failed under a policy that reports or exits on it, or the input could not be read. |
Hashing and encoding
Peios / Using Peios / Peiosutils / Hashing and encoding
This topic covers two related jobs. Hashing reduces a file to a short fixed-size value — a checksum — that can be used to tell whether the file has changed. Encoding rewrites binary data as plain text so it can travel safely through channels that only handle text.
The commands #
Checksums and hashes
| Command | Purpose |
|---|---|
| checksum commands | md5sum, sha1sum, sha256sum, and the rest — one command per hash algorithm. |
cksum | The general checksum tool — any of the algorithms, selected by an option. |
sum | A small, legacy block-checksum. |
Encoding
| Command | Purpose |
|---|---|
base32 and base64 | Encode binary data as text using the base32 or base64 alphabet, and decode it back. |
basenc | The general encoder — base64, base32, base16, and several more, selected by an option. |
What these commands are not #
It is worth being clear up front, because both jobs are easy to mistake for something they are not.
A hash is not encryption. Hashing is one-way: a checksum tells you whether data has changed, but the original cannot be recovered from it. It protects against corruption and detects tampering — it does not keep anything secret.
Encoding is not encryption either. base64 rewrites data into a text-safe form, but anyone can decode it straight back — it is a change of representation, not of secrecy. Encoding something does not protect it.
Neither hashing nor encoding hides data. They are about integrity and transport, not confidentiality.
Where to start #
To verify a file you downloaded against a published checksum, read the checksum commands. To turn binary data into something safe to paste into text, read base32 and base64.
Checksum commands
Peios / Using Peios / Peiosutils / Hashing and encoding
This page covers a family of commands that all work the same way and differ only in which hash algorithm they use:
| Command | Algorithm | Digest size |
|---|---|---|
md5sum | MD5 | 128-bit |
sha1sum | SHA-1 | 160-bit |
sha224sum | SHA-224 | 224-bit |
sha256sum | SHA-256 | 256-bit |
sha384sum | SHA-384 | 384-bit |
sha512sum | SHA-512 | 512-bit |
b2sum | BLAKE2b | up to 512-bit |
sha256sum [options] [file...]
Everything below is written with sha256sum, but applies to every command in the table.
Computing a checksum #
Run plainly, the command prints the hash of each file, followed by the file name:
$ sha256sum installer.iso
9f86d0818884...b1a5 installer.iso
With no file, it reads standard input. The hash is a fingerprint of the file's contents: change a single byte and the hash changes completely.
Verifying with a checksum #
The everyday use is verification — confirming a file is exactly what it should be, usually a download.
First, the file's publisher computes a checksum and publishes it, often in a file:
$ sha256sum installer.iso > installer.iso.sha256
Then anyone with the file and that checksum file can verify:
$ sha256sum -c installer.iso.sha256
installer.iso: OK
-c reads each hash filename line, recomputes the hash, and reports OK or FAILED. A FAILED means the file is not the one the checksum was made from — corrupted in transit, or altered.
| Option | Effect |
|---|---|
-c, --check | Read checksums from the given files and verify them. |
--ignore-missing | In check mode, do not fail over files that are listed but absent. |
--quiet | In check mode, print nothing for files that pass — only failures. |
--status | In check mode, print nothing at all; report only through the exit status. |
-w, --warn | Warn about improperly formatted lines in the checksum file. |
--strict | In check mode, fail if any checksum line is malformed. |
Output format #
| Option | Effect |
|---|---|
--tag | Produce a tagged line — SHA256 (file) = hash — that records which algorithm was used. |
--untagged | Produce the plain hash file line. This is the default. |
-z, --zero | End each output line with a NUL character instead of a newline. |
-b, --binary | Note the file as read in binary mode. |
-t, --text | Note the file as read in text mode. |
b2sum additionally accepts -l, --length=BITS to produce a shorter BLAKE2b digest.
A note on choosing an algorithm #
These commands detect change — but not all of them resist a deliberate attempt to forge a match. MD5 and SHA-1 can be defeated by an attacker who wants two different files to share a checksum. They are still fine for catching accidental corruption, but for verifying that a file has not been tampered with, use a SHA-2 command (sha256sum and up) or b2sum.
Exit status #
| Code | Meaning |
|---|---|
0 | The checksums were computed, or — in check mode — every file verified. |
1 | In check mode, a file failed verification. |
2 | An error — a file could not be read, or a checksum file was malformed. |
cksum
Peios / Using Peios / Peiosutils / Hashing and encoding
cksum is the general checksum command. Where each of the checksum commands is fixed to one algorithm, cksum does any of them — the algorithm is an option.
cksum [options] [file...]
$ cksum installer.iso
3915528286 372736000 installer.iso
$ cksum -a sha256 installer.iso
9f86d0818884...b1a5 installer.iso
With no -a, cksum computes a CRC checksum and prints the CRC value, the file's byte count, and the name. With -a, it behaves like the corresponding dedicated command.
Choosing the algorithm #
| Option | Effect |
|---|---|
-a, --algorithm=NAME | Use the named algorithm. |
NAME may be:
| Name | Algorithm |
|---|---|
crc | The default CRC. |
crc32b | A CRC-32 variant. |
md5 | MD5 — as md5sum. |
sha1 | SHA-1 — as sha1sum. |
sha224, sha256, sha384, sha512 | The SHA-2 family — as the matching sha*sum. |
sha3 | SHA-3. |
blake2b | BLAKE2b — as b2sum. |
sm3 | The SM3 hash. |
sysv, bsd | The legacy block checksums — as sum. |
crc, crc32b, sha3, and sm3 are available only through cksum — there is no dedicated command for them.
Verifying #
cksum checks files the same way the dedicated commands do:
| Option | Effect |
|---|---|
-c, --check | Read checksums and verify the files against them. |
--ignore-missing | Do not fail over listed files that are absent. |
--quiet | Print nothing for files that pass. |
--status | Print nothing; report only through the exit status. |
-w, --warn | Warn about malformed checksum lines. |
--strict | Fail on a malformed checksum line. |
Output format #
| Option | Effect |
|---|---|
--tag | Produce a tagged ALGORITHM (file) = hash line. The default for most algorithms. |
--untagged | Produce a plain hash file line. |
--raw | Output the raw binary digest, with nothing else. |
--base64 | Print the digest in base64 rather than hexadecimal. |
-l, --length=BITS | For an algorithm that supports it, produce a shorter digest. |
-z, --zero | End each output line with a NUL character. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success — or, in check mode, every file verified. |
1 | In check mode, a file failed verification. |
2 | An error — a file could not be read, or an option was invalid. |
sum
Peios / Using Peios / Peiosutils / Hashing and encoding
sum computes a small, old-style checksum of a file and reports it along with the file's size in blocks.
sum [options] [file...]
$ sum archive.tar
12345 84 archive.tar
The output is the checksum, the block count, and the file name. With no file, sum reads standard input.
Which algorithm #
sum predates the modern hashes and offers two historical algorithms:
| Option | Algorithm |
|---|---|
-r | The BSD checksum, counted in 1 KiB blocks. This is the default. |
-s, --sysv | The System V checksum, counted in 512-byte blocks. |
When to use it #
sum exists for compatibility — for reading checksums produced by old tools, and for scripts that still expect its output. Its checksum is short and weak: it catches some accidental corruption, but it is easily fooled and gives no protection against tampering.
For anything new, do not use sum. Use a checksum command such as sha256sum, or cksum — both of which cksum can still produce, via cksum -a bsd and cksum -a sysv, if you need the legacy values.
Exit status #
| Code | Meaning |
|---|---|
0 | The checksum was computed. |
1 | A file could not be read. |
base32 and base64
Peios / Using Peios / Peiosutils / Hashing and encoding
base32 and base64 encode binary data as plain text — and decode it back. They are the same command with one difference: the alphabet they use.
base32 [options] [file]
base64 [options] [file]
$ echo Peios | base64
UGVpb3MK
$ echo UGVpb3MK | base64 -d
Peios
What encoding is for #
Some channels only carry text safely — they mangle or reject arbitrary bytes. Encoding rewrites binary data using a small, safe set of characters, so it can pass through unharmed; decoding reverses it exactly. base64 uses 64 characters and is the more compact; base32 uses 32 and is more robust where case might not survive. With no file, both read standard input.
Encoding is not encryption — anyone can decode the result. See the overview.
Options #
By default these commands encode. The options below apply identically to both.
| Option | Effect |
|---|---|
-d, --decode | Decode instead of encode. |
-i, --ignore-garbage | When decoding, skip any characters that are not part of the alphabet, rather than failing on them. |
-w, --wrap=COLS | When encoding, wrap the output to lines of COLS characters. The default is 76; -w 0 disables wrapping and produces one unbroken line. |
When decoding, newlines in the input are always tolerated; -i is for other stray characters.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or — when decoding — the input was not valid for the alphabet. |
basenc
Peios / Using Peios / Peiosutils / Hashing and encoding
basenc is the general encoding command. Where base32 and base64 each do one encoding, basenc does many — the encoding is chosen by an option.
basenc encoding [options] [file]
$ echo Peios | basenc --base16
5065696F730A
Every run of basenc must name an encoding. With no file, it reads standard input.
The encodings #
| Option | Encoding |
|---|---|
--base64 | Standard base64 — the same as the base64 command. |
--base64url | Base64 with a file- and URL-safe alphabet. |
--base32 | Standard base32 — the same as the base32 command. |
--base32hex | Base32 with the extended-hex alphabet. |
--base16 | Hexadecimal. |
--base2msbf | A bit string, most-significant bit first. |
--base2lsbf | A bit string, least-significant bit first. |
--z85 | A compact, ASCII85-style encoding. When encoding, the input length must be a multiple of 4; when decoding, a multiple of 5. |
--base58 | Base58 — an alphabet chosen so its characters are not visually confusable. |
Options #
By default basenc encodes. These options apply to whichever encoding you chose:
| Option | Effect |
|---|---|
-d, --decode | Decode instead of encode. |
-i, --ignore-garbage | When decoding, skip characters that are not part of the alphabet. |
-w, --wrap=COLS | When encoding, wrap output to lines of COLS characters. -w 0 disables wrapping. |
basenc and the dedicated commands #
For plain base64 or base32, base64 and base32 are the shorter way to ask. Use basenc when you need an encoding the dedicated commands do not offer — hex, URL-safe base64, the bit-string forms, z85, or base58.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | No encoding was named, a file could not be read, or the input was not valid for the chosen encoding. |
System and processes
Peios / Using Peios / Peiosutils / System and processes
This topic gathers the commands that deal with the running system rather than with files: reading the time, asking what hardware and system you are on, launching another command under some constraint, signalling a process, working with the terminal.
The commands #
Time
| Command | Purpose |
|---|---|
date | Print — or set — the system date and time. |
sleep | Pause for a given length of time. |
uptime | Show how long the system has been running. |
Running a command under a constraint
| Command | Purpose |
|---|---|
timeout | Run a command, and kill it if it runs too long. |
nohup | Run a command so it survives the terminal closing. |
nice | Run a command at an adjusted scheduling priority. |
stdbuf | Run a command with altered stream buffering. |
chroot | Run a command with a different directory as its root. |
Signalling processes
| Command | Purpose |
|---|---|
kill | Send a signal to a process. |
System identity and capacity
| Command | Purpose |
|---|---|
uname | Print system information — the OS, the kernel, the machine. |
arch | Print the machine's hardware architecture. |
hostname | Display — or set — the system's host name. |
hostid | Print the host's numeric identifier. |
nproc | Print how many processor cores are available. |
Who you are
| Command | Purpose |
|---|---|
whoami | Print the name of the user a process is running as. |
id | Print the user and group identity of a process, or of a named user. |
groups | Print the groups a user belongs to. |
logname | Print the login name of the user a process is running as. |
These report the projection of a token onto POSIX user and group numbers, which is what Linux programs see. The numbers grant nothing on their own — id explains what they can and cannot say, and token shows the identity underneath them.
Storage and terminal
| Command | Purpose |
|---|---|
sync | Flush cached writes out to persistent storage. |
tty | Print the name of the terminal on standard input. |
stty | Display or change terminal settings. |
A note on changing the system #
Most commands here only report. A few can change the running system — date can set the clock, hostname can set the host name. Changing system-wide state is a privileged operation: it succeeds only for a caller whose token holds the right to do it, and is refused otherwise. The pages for those commands say so where it applies, and Privileges is the full picture.
Where to start #
uname answers "what am I running on?". kill is the one to read carefully — sending the wrong signal to the wrong process is a quick way to lose work.
date
Peios / Using Peios / Peiosutils / System and processes
date prints the current date and time. With the right option, it can also set the system clock, display some other moment, or format a date however you need.
date [options] [+format]
$ date
Sun 17 May 2026 14:32:08 BST
$ date +%Y-%m-%d
2026-05-17
Formatting the output #
A +format argument controls the output. The format string is printed literally, except for % sequences, each of which is replaced by a piece of the date. The common ones:
| Sequence | Is replaced by |
|---|---|
%Y %m %d | Year, month, day. |
%H %M %S | Hour, minute, second. |
%F | The full date — same as %Y-%m-%d. |
%T | The time — same as %H:%M:%S. |
%A %a | Weekday name, full and abbreviated. |
%B %b | Month name, full and abbreviated. |
%j | Day of the year. |
%s | Seconds since 1970-01-01 UTC. |
%z %Z | Numeric and named time zone. |
%% | A literal %. |
The full set is long; date --help lists every sequence with an example.
Displaying a different moment #
| Option | Effect |
|---|---|
-d, --date=STRING | Show the time described by STRING — "yesterday", "next Friday", "@1615432800" — instead of now. |
-r, --reference=FILE | Show FILE's last modification time. |
-f, --file=DATEFILE | Like -d, but once for each line of DATEFILE. |
-u, --universal | Work in Coordinated Universal Time (UTC) rather than the local zone. |
Standard formats #
| Option | Output |
|---|---|
-I, --iso-8601[=FMT] | ISO 8601 — FMT is date, hours, minutes, seconds, or ns. |
-R, --rfc-email | The format used in email headers. |
--rfc-3339=FMT | RFC 3339 — FMT is date, seconds, or ns. |
Setting the clock #
| Option | Effect |
|---|---|
-s, --set=STRING | Set the system clock to the time described by STRING. |
Setting the clock changes state for the whole system, so it is a privileged operation — it succeeds only for a caller whose token carries the right to set the time, and is refused otherwise. See Privileges.
Exit status #
| Code | Meaning |
|---|---|
0 | The date was printed or set. |
1 | An invalid date or format, or the clock could not be set. |
sleep
Peios / Using Peios / Peiosutils / System and processes
sleep does nothing for a given length of time, then exits.
sleep number[suffix]...
$ sleep 5 # pause for five seconds
$ sleep 1.5h # pause for an hour and a half
It is used to space things out — a pause between the steps of a script, a delay before a retry.
Specifying the duration #
number may be a whole number or a fraction. A suffix gives the unit:
| Suffix | Unit |
|---|---|
s | Seconds. This is the default if no suffix is given. |
m | Minutes. |
h | Hours. |
d | Days. |
Given more than one argument, sleep waits for the sum of them — sleep 1m 30s pauses for ninety seconds.
Exit status #
| Code | Meaning |
|---|---|
0 | The full time elapsed. |
1 | A bad argument, or sleep was interrupted before the time was up. |
timeout
Peios / Using Peios / Peiosutils / System and processes
timeout runs a command with a time limit. If the command finishes in time, nothing special happens. If it is still running when the limit is reached, timeout kills it.
timeout [options] duration command...
$ timeout 30s slow-fetch https://example.com
It is the guard against a command that might hang — a network fetch, a script that could loop forever.
The duration #
duration is a number with an optional unit suffix: s for seconds (the default), m for minutes, h for hours, d for days. A duration of 0 disables the limit.
How the command is stopped #
When the limit is reached, timeout sends the command a TERM signal — a request to shut down cleanly. A well-behaved command stops there. A command that ignores TERM keeps running, which is what -k is for.
| Option | Effect |
|---|---|
-s, --signal=SIGNAL | Send SIGNAL instead of TERM when the limit is reached. |
-k, --kill-after=DURATION | If the command is still running DURATION after the first signal, send it a KILL signal — which cannot be ignored. |
--preserve-status | Exit with the command's own status even when it timed out. |
--foreground | Allow the command to keep reading from the terminal; do not time out the command's own children. |
-v, --verbose | Report to standard error whenever a signal is sent. |
Exit status #
timeout passes through the command's exit status when the command finishes on its own. Otherwise:
| Code | Meaning |
|---|---|
| (command's own) | The command finished within the limit. |
124 | The command timed out. |
125 | timeout itself failed. |
126 | The command was found but could not be run. |
127 | The command was not found. |
137 | The command was ended by a KILL signal. |
nohup
Peios / Using Peios / Peiosutils / System and processes
nohup runs a command so that it survives the terminal closing. Normally, when a terminal session ends, the system sends a hangup signal to the commands running under it, and they stop. A command started with nohup ignores that signal and keeps running.
nohup [options] command [arg]...
$ nohup long-build &
It is how you start something that needs to outlast your session — a long build, a background job — without it being killed the moment you disconnect.
Where the output goes #
A command run with nohup is meant to outlive the terminal, so nohup cannot leave its streams attached to one. It redirects any stream that is still connected to a terminal:
- Standard input, if it is a terminal, is replaced with an empty source — the command reads nothing.
- Standard output, if it is a terminal, is appended to a file named
nohup.outin the current directory. If that file cannot be opened there,nohupfalls back tonohup.outin your home directory. - Standard error, if it is a terminal, is sent to wherever standard output now goes.
Streams that you have already redirected yourself — to a file, or a pipe — are left as they are.
How nohup.out is secured #
When nohup has to create nohup.out, that file is a new file and needs a security descriptor. nohup does not let it inherit one from the directory. It creates nohup.out with a deliberate, locked descriptor: full access for the owner, and no one else.
The reasoning is that nohup.out captures whatever the command writes, which the person running it has not chosen to share. A file that appears as a side effect should not be more open than its creator intended — so nohup gives it the safe default of owner-only, rather than whatever the surrounding directory would have handed down.
| Option | Effect |
|---|---|
--sddl=SDDL | Create nohup.out with the security descriptor given as an SDDL string, instead of the owner-only default. |
--sddl only matters when nohup actually creates the file. If nohup.out already exists, nohup appends to it and leaves its existing security alone.
Exit status #
When nohup runs a command, it exits with that command's status once it finishes. The exception is a failure in nohup itself:
| Code | Meaning |
|---|---|
| (command's own) | The command ran; this is its exit status. |
125 | nohup could not set things up — it could not detach from the terminal, or could not open nohup.out anywhere. The command was not run. |
nice
Peios / Using Peios / Peiosutils / System and processes
nice runs a command at an adjusted scheduling priority — making it more, or less, willing to give the processor up to other work.
nice [options] [command [arg]...]
$ nice -n 15 big-batch-job
With no command, nice prints the current niceness.
Niceness #
A process has a niceness — a number from -20 to 19:
- A high niceness (toward 19) means the process is "nicer" to others — it yields the processor readily. Good for background work that should not get in the way.
- A low niceness (toward -20) means the process is favoured — it gets the processor more often.
By default nice adds 10 to the niceness, making the command run in the background more gracefully.
| Option | Effect |
|---|---|
-n, --adjustment=N | Add N to the niceness instead of 10. N may be negative. |
Raising a command's niceness — running it lower priority — is always allowed. Lowering the niceness, to claim a higher priority than normal, is a privileged request and may be refused; when it is, nice warns and runs the command at the priority it was permitted.
Exit status #
When nice runs a command, it exits with that command's status. The exception is a failure in nice itself:
| Code | Meaning |
|---|---|
| (command's own) | The command ran; this is its exit status. |
0 | No command was given; the niceness was printed. |
125 | nice itself failed — for example, a bad adjustment value. |
126 | The command was found but could not be run. |
127 | The command was not found. |
kill
Peios / Using Peios / Peiosutils / System and processes
kill sends a signal to a process. Despite the name, a signal is not always fatal — it is a message, and some signals ask a process to do something other than stop.
kill [options] pid...
$ kill 4821 # ask process 4821 to terminate
$ kill -KILL 4821 # force it to stop
A pid is a process identifier — the number a running process is known by.
Signals #
With no option, kill sends TERM — a polite request to shut down, which a well-behaved process honours by cleaning up and exiting. The signals you will use most:
| Signal | Meaning |
|---|---|
TERM | Terminate. The default — a request to stop cleanly. |
KILL | Stop immediately. Cannot be caught or ignored, so it cannot be refused — but the process gets no chance to clean up. The last resort. |
HUP | Hang up. Conventionally tells a long-running service to reload its configuration. |
INT | Interrupt — the signal a terminal sends on Ctrl-C. |
STOP / CONT | Suspend the process / resume a suspended one. |
| Option | Effect |
|---|---|
-s, --signal=SIGNAL | Send SIGNAL instead of TERM. |
-SIGNAL | A shorthand — -KILL or -9 both send KILL. |
-l, --list | List the signal names. |
-L, --table | List the signals as a table of numbers and names. |
Use KILL only when TERM has failed. A process killed outright cannot save its work or release what it holds.
Exit status #
| Code | Meaning |
|---|---|
0 | Every signal was sent. |
1 | A signal could not be sent — a process that does not exist, or one you are not permitted to signal. |
uname
Peios / Using Peios / Peiosutils / System and processes
uname prints information about the system you are running on.
uname [options]
$ uname
Peios
$ uname -a
Peios host-01 1.4.0 #1 SMP 2026-05-10 x86_64 Peios
With no option, uname prints the operating-system name — Peios.
What each option prints #
| Option | Prints |
|---|---|
-s | The kernel name. |
-o | The operating-system name — Peios. |
-n | The node name — the name this system is known by on the network. |
-r | The kernel release. |
-v | The kernel version. |
-m | The machine's hardware name (its architecture). |
-p | The processor type. |
-i | The hardware platform. |
-a | Everything — equivalent to -mnrsvo. |
Give several options and uname prints those fields, in a fixed order, on one line.
The operating system is Peios #
uname -o, and the operating-system field of uname -a, report Peios. This is the field a script should check when it wants to confirm what system it is on.
For the machine architecture alone, arch is the shorter command — it prints exactly what uname -m prints.
Exit status #
| Code | Meaning |
|---|---|
0 | The requested information was printed. |
1 | The system information could not be read. |
arch
Peios / Using Peios / Peiosutils / System and processes
arch prints the machine's hardware architecture — the kind of processor the system runs on.
arch
$ arch
x86_64
That is the whole command. It takes no options and prints a single name.
arch prints exactly what uname -m prints. It exists as a short, obvious name for the one question "what architecture is this?" — convenient in a script that picks an architecture-specific path.
Exit status #
| Code | Meaning |
|---|---|
0 | The architecture was printed. |
1 | The architecture could not be determined. |
hostname
Peios / Using Peios / Peiosutils / System and processes
hostname displays the system's host name — the name the system is known by.
hostname [options] [name]
$ hostname
host-01.example.com
What is displayed #
By default hostname prints the fully qualified name. These options narrow or change what is shown:
| Option | Prints |
|---|---|
-f, --fqdn | The fully qualified domain name — the full host.domain form. This is the default. |
-s, --short | The short host name only — the part before the first dot. |
-d, --domain | The DNS domain part only. |
-i, --ip-address | The network address (or addresses) the host name resolves to. |
Setting the host name #
Given a name argument, hostname sets the system's host name to it.
$ hostname host-02
Changing the host name changes state for the whole system, so it is a privileged operation — it succeeds only for a caller whose token carries the right to do it, and is refused otherwise. See Privileges.
Exit status #
| Code | Meaning |
|---|---|
0 | The host name was displayed or set. |
1 | A failure — the name could not be read, set, or resolved. |
hostid
Peios / Using Peios / Peiosutils / System and processes
hostid prints the host's numeric identifier, in hexadecimal.
hostid
$ hostid
007f0100
The host id is a number associated with the system. It takes no options and prints one value.
Where hostname gives the system's name — meant to be read by people — hostid gives a numeric value, used by software that wants a machine identifier in a fixed numeric form.
Exit status #
| Code | Meaning |
|---|---|
0 | The host id was printed. |
nproc
Peios / Using Peios / Peiosutils / System and processes
nproc prints the number of processor cores available.
nproc [options]
$ nproc
8
By default it prints the number of cores available to the current process — which can be fewer than the machine has, if the process has been restricted to a subset. It is most often used to decide how many parallel jobs to run.
Options #
| Option | Effect |
|---|---|
--all | Print the number of cores the whole system has, ignoring any restriction on the current process. |
--ignore=N | Subtract N from the count — for leaving some cores free rather than using every one. |
The environment variables OMP_NUM_THREADS and OMP_THREAD_LIMIT, if set, also bound the number nproc reports.
Exit status #
| Code | Meaning |
|---|---|
0 | The count was printed. |
1 | An option value was invalid. |
uptime
Peios / Using Peios / Peiosutils / System and processes
uptime shows how long the system has been running, along with a few related figures.
uptime [options]
$ uptime
14:32:08 up 6 days, 3:21, load average: 0.18, 0.24, 0.21
The default line packs in three things:
- the current time;
- how long the system has been up;
- the load average — a rough measure of how busy the system has been over the last 1, 5, and 15 minutes.
If you know uptime from other systems you may be expecting a count of logged-in users between the uptime and the load average. Peios does not print one. That count comes from a login database (utmp) that Peios does not keep — logon sessions are a KACS concept, and the tooling to report them is not built yet. Rather than print a figure that would always read 0 users, the field is left out until it can be answered honestly.
Options #
| Option | Effect |
|---|---|
-p, --pretty | Show just the uptime, in a readable phrase — up 6 days, 3 hours, 21 minutes. |
-s, --since | Show the date and time the system started, rather than how long ago that was. |
Exit status #
| Code | Meaning |
|---|---|
0 | The uptime was printed. |
1 | The boot time could not be read. |
sync
Peios / Using Peios / Peiosutils / System and processes
sync flushes cached writes to persistent storage.
sync [options] [file...]
$ sync
When a program writes to a file, the data does not always reach the physical device immediately — for speed, the system holds recent writes in memory and writes them out a little later. sync forces that pending data out now, so that what is on the device matches what programs have written.
It is the command to run before doing something that should not lose recent writes — before powering off by hand, or before removing storage.
Options #
With no argument, sync flushes everything. The options narrow it:
| Option | Effect |
|---|---|
file... | Flush only the given files, rather than everything. |
-d, --data | For the given files, flush only the file data, not metadata that does not need it. |
-f, --file-system | Flush the entire file system that contains each given file. |
Exit status #
| Code | Meaning |
|---|---|
0 | The flush completed. |
1 | A named file could not be reached. |
tty
Peios / Using Peios / Peiosutils / System and processes
tty prints the name of the terminal connected to standard input.
tty [options]
$ tty
/dev/pts/3
If standard input is not a terminal — because it has been redirected from a file or a pipe — tty prints not a tty instead.
That second behaviour is the useful one: a script can run tty to find out whether it is being run interactively or as part of a pipeline, and behave accordingly.
Options #
| Option | Effect |
|---|---|
-s, --silent | Print nothing; report the answer only through the exit status. |
Exit status #
| Code | Meaning |
|---|---|
0 | Standard input is a terminal. |
1 | Standard input is not a terminal. |
2 | A usage error. |
stty
Peios / Using Peios / Peiosutils / System and processes
stty displays and changes the settings of a terminal — how it handles input, output, and special keys.
stty [options] [setting...]
$ stty
speed 38400 baud; line = 0;
-brkint -imaxbel iutf8
Run with no arguments, stty prints the settings that differ from the usual defaults. Given setting arguments, it changes them.
Viewing the settings #
| Option | Effect |
|---|---|
-a, --all | Print every setting, in a readable layout. |
-g, --save | Print every setting in a single compact line — a form that can be fed straight back to stty to restore them. |
The -g form is how you save and restore a terminal's state:
$ saved=$(stty -g) # capture the current state
$ stty -echo # ...change something...
$ stty "$saved" # restore exactly what was saved
Changing the settings #
A setting argument turns something on or off, or assigns a value. A few common ones:
| Setting | Effect |
|---|---|
echo / -echo | Show / hide typed characters — -echo is how a password prompt hides input. |
rows N / cols N | Tell the terminal its size. |
| A number | Set the connection speed (baud rate). |
stty has a large catalog of settings; stty -a shows them all with their current values.
Choosing the terminal #
| Option | Effect |
|---|---|
-F, --file=DEVICE | Operate on DEVICE instead of the terminal on standard input. |
Exit status #
| Code | Meaning |
|---|---|
0 | The settings were printed or changed. |
1 | A setting was invalid, or the terminal could not be accessed. |
stdbuf
Peios / Using Peios / Peiosutils / System and processes
stdbuf runs a command with altered buffering on its standard streams.
stdbuf [options] command...
$ slow-producer | stdbuf -oL grep error
What buffering is, and why change it #
A program usually does not write each piece of output the instant it is produced — it collects output in a buffer and writes it in batches, which is faster. The cost is delay: when a program's output feeds a pipe, that batching can hold lines back for a long time, which is unhelpful when you are watching a pipeline live.
stdbuf lets you override that batching for a command, so its output appears sooner.
Setting the buffering #
| Option | Stream |
|---|---|
-i, --input=MODE | Standard input. |
-o, --output=MODE | Standard output. |
-e, --error=MODE | Standard error. |
MODE is one of:
| MODE | Buffering |
|---|---|
0 | Unbuffered — every write goes out immediately. |
L | Line-buffered — output is flushed at the end of each line. Not valid for input. |
| a size | Fully buffered with a buffer of that many bytes. Accepts suffixes — K, M, and so on. |
-oL — line-buffered output — is the common case: it makes a command in a pipeline emit each line as it is finished.
A limitation #
stdbuf adjusts the default buffering. A command that manages its own stream buffering will override what stdbuf sets, and some commands do not use buffered streams at all — for those, stdbuf has no effect.
Exit status #
When stdbuf runs a command, it exits with that command's status. The exception is a failure in stdbuf itself:
| Code | Meaning |
|---|---|
| (command's own) | The command ran; this is its exit status. |
(a stdbuf-level error code) | The command could not be started. |
chroot
Peios / Using Peios / Peiosutils / System and processes
chroot runs a command with a different directory as its root — its /. Inside that command, the chosen directory is the top of the file system, and nothing above it can be named or reached.
chroot newroot [command [arg]...]
$ chroot /mnt/system /bin/sh
With a command, chroot changes the root to newroot and runs the command there. With no command, it starts an interactive shell.
What it does #
A process normally sees the whole file system, rooted at the real /. After chroot, the named directory becomes that process's /: a path like /etc/config inside the command resolves to newroot/etc/config, and there is no path that reaches outside newroot at all.
It is used to work inside another installed system — repairing or configuring a system image by entering it — and to run a command confined to a known subtree.
For the command to be usable, newroot must already contain what the command needs: the command's own executable, and whatever libraries and files it depends on, all present under newroot.
Options #
| Option | Effect |
|---|---|
--skip-chdir | Do not change the working directory to / after changing the root. Permitted only when newroot is the current /. |
A privileged operation #
Changing a process's root directory is a privileged operation. chroot succeeds only for a caller whose token carries the right to do it, and is refused otherwise — the ability to redraw a process's view of the file system is not something every principal holds. See Privileges.
Exit status #
chroot exits with the command's own status once it finishes. Otherwise:
| Code | Meaning |
|---|---|
125 | chroot itself failed — for example, newroot is not a directory. |
126 | The command was found but could not be run. |
127 | The command was not found. |
whoami
Peios / Using Peios / Peiosutils / System and processes
whoami prints the name of the user the calling process is running as.
whoami
$ whoami
jack
It takes no arguments. It is the same answer as id -un, which is all it has ever been.
Where the name comes from #
The name is resolved from the process's effective user ID, which is a projection of the token's user SID. The lookup goes to the authority — there is no /etc/passwd to read — and comes back as the principal's canonical name. Resolving names is the full picture.
The name is the principal's; the number it was looked up by is not what grants anything. If you want the identity access is actually decided against, id -Z prints the SID.
Exit status #
| Code | Meaning |
|---|---|
0 | The name was printed. |
1 | The user ID could not be resolved to a name. |
A failure here usually means the authority could not be reached rather than that the account is missing — identity does not resolve before the authority is running, which is the correct answer rather than a gap.
id
Peios / Using Peios / Peiosutils / System and processes
id prints who a process is: its user, its primary group, the groups it belongs to, and its security context.
id [options] [USER]...
$ id
uid=5001000(jack) gid=5000513(Users) groups=5000513(Users),100(Everyone),101(Authenticated Users),1544(Administrators) context=S-1-5-21-1004336348-1177238915-682003330-1000:High
With no argument it reports the calling process. Given one or more users, it reports on those instead.
The numbers are projections #
This is the thing to understand before reading id's output on Peios.
The uid= and gid= numbers are projections of the token's SIDs. They are real, stable, and exactly what Linux programs see and what filesystems store — but nothing is decided by them. Access is decided against the SID the number was projected from, by the access check. A number here grants nothing.
That distinction is not academic, because the projection cannot carry everything a token holds:
- Groups whose SIDs are deliberately unnumbered —
Interactive,Network,Batch,Service— cannot appear at all. They are what make a rule like "network logons cap at Low" expressible, and they have no gid by design. - A group's attributes have nowhere to go. A deny-only group, which can block access through a deny entry but grant nothing through an allow entry, appears in
groups=looking exactly like an ordinary membership. - The integrity level and the logon session have no number either.
So two tokens can project to identical numbers and mean different things. The clearest case is a filtered token: it carries the same user SID and the same group numbers as the token it came from, and differs only in attributes the projection cannot express.
That is why the default line ends with a context= field.
The context field #
context= is the authoritative half: the user SID access is actually decided against, and the integrity level.
context=S-1-5-21-1004336348-1177238915-682003330-1000:High
The integrity level is part of it precisely because the SID alone does not separate an elevated token from its filtered counterpart — both carry the same user SID, and the level is what differs.
If you know id from another Unix, this is the field that carries the SELinux context there. Peios has no SELinux; its security context is the token, so that is what the field holds.
context= is omitted when a user is named rather than the calling process — a named user has no token here to read — and when POSIXLY_CORRECT is set, so a caller that asked for the POSIX shape still gets it.
For the whole token rather than this summary, use token.
Options #
| Option | Effect |
|---|---|
-u, --user | Print only the effective user ID. |
-g, --group | Print only the effective group ID. |
-G, --groups | Print only the group IDs, space-separated. |
-n, --name | With -u, -g or -G, print names instead of numbers. |
-r, --real | With -u, -g or -G, print the real ID rather than the effective one. |
-z, --zero | Separate entries with NUL rather than whitespace. Not allowed in the default format. |
-Z, --context | Print only the security context. |
-p | A readable, multi-line form. |
-P | Print as a password-file entry. |
-a | Ignored; accepted for compatibility. |
-A | Not applicable on Peios — see below. |
-n and -r need one of -u, -g or -G; they do not change the default format.
-r means something different here #
On other Unixes the real/effective split is the setuid boundary. On Peios a setuid bit does not change a token, so that boundary does not exist. What the split tracks instead is impersonation: the real IDs come from the process's own token, the effective ones from whatever token is in force.
id never impersonates, so in practice it reports the same either way and the euid=/egid= fields never appear. That is not a fault — there is genuinely nothing differing to report.
-A is not applicable #
-A reports BSD audit session properties. Peios audits through its own event system and keeps no BSM session, so -A says so rather than silently succeeding at nothing.
id -G and id -G <yourself> differ #
Asking about yourself and asking about your name are two different questions here, and they give different answers:
id -Greads your process's credential — the projection of your token's group set, which includes the groups the authority added when the token was minted:Everyone,Authenticated Users, and anyAdministrators-style aliases.id -G jacklooks the name up through the principal store, which returns recorded memberships only. The stapled groups are not recorded anywhere, because membership in them is a rule rather than a stored fact — so they do not come back.
On other systems these two differ only in the order they print. Here the second list is genuinely shorter, and neither is wrong.
Exit status #
| Code | Meaning |
|---|---|
0 | The identity was printed. |
1 | A user could not be found, an option combination was rejected, or the context could not be read. |
groups
Peios / Using Peios / Peiosutils / System and processes
groups prints the groups a user belongs to.
groups [USERNAME]...
$ groups
Users Everyone Authenticated Users Administrators
$ groups jack
jack : Users Administrators
With no argument it reports the calling process. Given names, it reports on those.
Those two answers are both correct #
The example above is not a mistake, and it is the thing worth knowing about this command on Peios.
groups reads the calling process's credential — the projection of its token's group set. That set includes the groups the authority stapled on when the token was minted: Everyone, Authenticated Users, and the like.
groups jack looks the name up through the principal store, which returns recorded memberships — the ones something actually wrote down.
The stapled groups are not recorded anywhere, because membership in them is a rule, not a stored fact. Everyone is in Everyone; nothing needs to record it, and nothing can enumerate it. So they appear in the first answer and not the second.
On other Unixes these two forms differ only in the order they print.
What cannot appear #
Both forms are lossy in the same way, and unavoidably so — a group can only be listed if it has a group ID, and some deliberately do not:
Interactive,Network,BatchandServiceare unnumbered on purpose. They describe how you signed in rather than who you are, which is what lets a rule like "network logons cap at Low" be written at all. You are inInteractiveat the console and not over the network, so there is no static answer to record.- A group's attributes are not representable. A deny-only group — one that can block access through a deny entry but grant nothing through an allow entry — is printed exactly like an ordinary membership.
token groups is the lossless view, with every SID and its attributes.
Exit status #
| Code | Meaning |
|---|---|
0 | The groups were printed. |
1 | A named user could not be found, or the group list could not be read. |
A group ID with no name is printed as the bare number and sets the exit status, rather than stopping the listing.
logname
Peios / Using Peios / Peiosutils / System and processes
logname prints the login name the calling process runs under.
logname
$ logname
jack
It takes no arguments.
Login name, not current name #
logname and whoami look like the same command and answer slightly different questions.
whoami reports the effective identity — whoever the process is acting as right now. logname reports the login identity: the principal the process's own token is for, regardless of any token it has since taken on. When a process is impersonating a client, whoami follows the impersonation and logname does not.
Most of the time nothing is impersonating and the two agree.
If you know logname from another Unix, note that it answers this from the token rather than from a login-record file. There is no utmp on Peios — nothing writes one — so the traditional source does not exist. The token is the record of who a process is, and it is a better one: it cannot drift from the identity access is actually checked against, because it is that identity.
Exit status #
| Code | Meaning |
|---|---|
0 | The login name was printed. |
1 | No login name could be determined. |
token
Peios / Using Peios / Peiosutils / System and processes
token is the command-line tool for working with tokens directly. It reads a token's contents, adjusts it, produces derived tokens, and drives impersonation — the low-level operations this topic describes, exposed at a shell.
token subcommand [target] [arguments]
$ token # one-line summary of your own token
$ token show --all # every field of your own token
$ token privs --pid 4821 # the privileges on process 4821's token
token is a direct, debug-level tool. Day to day you do not inspect tokens by hand — the system does. token is for diagnosing an access problem, for understanding what identity a process is really running under, and for building and testing identity setups. Run with no subcommand, it prints a one-line summary of your own token.
Choosing which token #
Almost every subcommand operates on a token, and these flags choose which one. With none, the target is your own.
| Flag | Target |
|---|---|
--self | Your own token. The default. |
--real | Your primary token specifically, rather than the effective one — relevant when your thread is impersonating. |
--pid PID | The primary token of process PID. |
--tid TID | The impersonation token of thread TID (used with --pid). |
--peer SOCK_FD | The peer's captured token on a connected socket — see Peer tokens. |
Reading another process's token is itself access-controlled: it succeeds only with the right authority over that process.
Inspecting a token #
show #
token show prints a token's contents. It is the default — bare token is token show --short.
| Flag | Effect |
|---|---|
--short | A one-line summary. |
--all | Every query class — the fullest dump. |
Field accessors #
Each of these prints one part of a token, for when you want just that piece:
| Subcommand | Prints |
|---|---|
user | The user SID — who the token is. |
owner | The default owner SID. |
group | The primary group SID. |
groups | The group list. |
privs | The privileges, with their enabled state. |
caps | The capabilities. |
claims | The user and device claims. |
integrity | The integrity level. |
logon | The logon type and logon SID. |
source | What minted the token. |
origin | The originating session for a derived token. |
stats | Token statistics — IDs, timestamps, the modification counter. |
default-dacl | The token's default DACL. |
query #
token query CLASS performs a raw read of a single named token-info class and prints the result as JSON — the lowest-level inspection route, for tooling.
Changing a token #
adjust #
token adjust mutates a token in place:
| Form | Changes |
|---|---|
adjust privs NAME=STATE … | Enable, disable, or remove privileges. STATE is enabled, disabled, or removed. |
adjust groups IDX=STATE … | Enable or disable groups by their list index. |
adjust default --dacl SDDL | Replace the token's default DACL. Also --owner-idx / --group-idx. |
adjust session ID | Replace the token's session id. |
restrict #
token restrict produces a restricted token — a more limited variant of a token.
| Flag | Effect |
|---|---|
--drop-privs MASK|NAMES | Privileges to drop. |
--deny IDX,… | Group indices to mark deny-only. |
--restrict SID,… | The restricting SIDs to apply. |
duplicate #
token duplicate (alias dup) copies a token, optionally changing its --type (primary or impersonation), its impersonation --level, or its --access mask.
link and linked #
token link joins two tokens as an elevation pair — a full token and its filtered counterpart — given their file descriptors and a session id. token linked shows a token's elevation-linked counterpart, if it has one. See Elevation.
Impersonation #
| Subcommand | Effect |
|---|---|
impersonate | Begin impersonating the target token on the calling thread. With a trailing -- command …, run that command under the impersonating token. |
revert | Drop any active impersonation on the calling thread. |
See Impersonation for the model these drive.
Creating tokens #
| Subcommand | Effect |
|---|---|
create SPEC | Create a token from a binary token-spec (SPEC is a file, or - for standard input). |
install SPEC | Create a token from a spec and install it as the caller's primary token. |
Creating and installing tokens is a privileged operation, reserved for the components that legitimately mint identity.
Output options #
| Flag | Effect |
|---|---|
--raw | Render SIDs in raw S-1-… form only. |
--label | Render SIDs as their labels where known, falling back to raw. |
--json | Emit JSON instead of human-readable output. |
token and the inspection surfaces #
token is the convenient front-end. Underneath, it reads the same kernel surfaces and rules described in Inspecting tokens — that page covers the query mechanism, the access rules for reading another process's token, and what cannot be inspected.
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded. |
1 | A usage error. |
| non-zero | The operation failed — no such target, an access denial, or a bad spec. |
logonse
Peios / Using Peios / Peiosutils / System and processes
logonse is the command-line tool for logon sessions — the kernel's records of authentication events that this topic describes. It lists the active sessions, shows which processes belong to one, creates and destroys sessions, and (as a related low-level job) sets a process's mitigation flags.
logonse subcommand [arguments]
$ logonse list
$ logonse show 4711
logonse is a low-level administrative and debugging tool. It requires a subcommand: list, show, create, destroy, or psb.
Listing sessions #
logonse list #
Enumerates the active logon sessions, and the process IDs in each.
$ logonse list
session 0 pids: [1, 2, 14, 22]
session 4711 pids: [820, 844, 901]
logonse show #
Shows the process IDs that belong to one session.
$ logonse show 4711
A caveat on list and show #
There is no syscall that enumerates logon sessions, and logonse does not read the kernel's sessions file (whose SD restricts it to Administrators and SYSTEM). logonse list and logonse show work by walking the running processes and reading each one's token to find which session it belongs to. That has two consequences worth knowing:
- It is best-effort. A session that has no running process — held alive only by a token file descriptor somewhere — will not appear, because there is no process to find it through.
- It is a snapshot under change. Processes start and exit while the walk runs, so the result is a close approximation of the moment, not a locked one.
For routine "who is signed in" use this is fine. For an authoritative listing, the kernel's own sessions surface — described in Inspecting sessions — is the source of record.
Creating and destroying sessions #
logonse create #
Creates a new logon session for a user, described by a logon type, an authentication-package name, and the user's SID.
$ logonse create --logon-type interactive --auth-package Negotiate --user-sid S-1-5-21-...-1001
| Flag | Meaning |
|---|---|
--logon-type TYPE | The kind of logon: interactive, network, batch, service, network-cleartext, or new-credentials. |
--auth-package STR | The name of the authentication package that vouched for the logon. |
--user-sid SID | The user the session belongs to, as an S-1-… SID or an SDDL alias such as BA. |
On success logonse prints the new session's id. Creating a session is a privileged operation — minting authentication records is reserved for the components that legitimately do so.
logonse destroy #
Destroys a session — but only an empty one, with no tokens still referencing it.
$ logonse destroy 4711
A session with live tokens cannot be destroyed this way; its tokens must go first. See Session lifecycle.
Setting process mitigation flags #
logonse psb #
logonse psb sets the mitigation flags in a process's Process Security Block.
$ logonse psb --pid 4821 --mitigations 0x1c0
| Flag | Meaning |
|---|---|
--pid PID | The process to act on. |
--mitigations MASK | The mitigation bitmask to apply, in hexadecimal or decimal. |
This subcommand is about process hardening rather than logon sessions — it lives in logonse because both deal with low-level per-process kernel state. For what the mitigation flags mean and how they behave, see Process mitigations.
Output options #
| Flag | Effect |
|---|---|
--json | Emit JSON instead of human-readable output. Accepted by every subcommand. |
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded. |
1 | A usage error, or the operation failed. |
mount
Peios / Using Peios / Peiosutils / Disks and mounts
mount attaches a filesystem to the Peios mount tree. With no operands it instead lists what is currently mounted. It is a faithful reworking of the util-linux mount(8) surface for Peios, with two structural differences: Peios has no /etc/fstab and no /etc/mtab, so every mount is described entirely on the command line and live mount state is read from the kernel; and a mount can apply a KACS mount policy to the new filesystem at attach time (see Mount policies).
mount [-t TYPE] [-o OPTIONS] SOURCE TARGET
mount [-o remount,OPTIONS] TARGET
mount --bind|--rbind|--move SOURCE TARGET
mount --make-shared|--make-private|... TARGET
mount [-l] [-t TYPE]
Internally mount uses the fd-based mount API (fsopen / fsconfig / fsmount / move_mount, plus open_tree and mount_setattr), never the classic single-shot mount(2). This matters in one place you can see: when a mount fails, the kernel's fs_context message log is drained and printed as the reason, even without -v.
Operands and argument shapes #
Because there is no fstab to consult, operand resolution is strict:
- No operands, no verb — list mode (see below).
- Two operands —
SOURCE TARGET. - One operand — an error. In util-linux a lone operand is resolved through fstab; Peios has none, so a single operand cannot be turned into a
SOURCE TARGETpair. Supply both, or use--source/--target. - A lone target is valid only for
-o remountand for a standalone propagation change (--make-*), which act on an existing mount point.
--source SRC and --target DIR name the operands explicitly and may be combined with a single positional. --target-prefix DIR prepends DIR/ to the target after it is chosen. Options may be interspersed with operands (mount SRC -o ro TGT), matching util-linux.
Paths, -o key=value values, labels and UUIDs are handled as opaque byte strings, never assumed to be UTF-8; an embedded NUL is a usage error.
Canonicalisation #
By default source and target paths are canonicalised (made absolute, symlinks resolved). -c / --no-canonicalize disables that; X-mount.nocanonicalize[=source|target] is the -o form and can disable it for just one side. The final path handed to the kernel is protected against a TOCTOU symlink swap on its last component.
Operation modes (verbs) #
mount performs one of several operations. The structural verbs — bind, rbind, move, beneath and remount — are mutually exclusive with one another. A propagation change is not a structural verb: it may stand alone on a target, or trail another verb or a new mount in the same command (applied afterwards as one or more mount_setattr steps), and several may be combined.
| Verb | How to request it | What it does |
|---|---|---|
| New mount | mount [-t T] SRC TGT | Attach a fresh instance of the filesystem at SRC onto TGT. |
| Bind | -B / --bind, or -o bind | Make an existing subtree visible at a second location. |
| Recursive bind | -R / --rbind, or -o rbind | Bind a subtree together with every mount underneath it. |
| Move | -M / --move, or -o move | Relocate an existing mount to a new mount point. |
| Move beneath | --beneath SRC TGT | Attach SRC beneath the mount currently at TGT (the kernel enforces several constraints; violations surface as exit 32). |
| Remount | -o remount[,...] TGT | Change the options of an existing mount (see Remounting). |
| Propagation | --make-* / --make-r*, or the -o tokens below | Change how mount/unmount events propagate across a subtree. |
| List | mount / mount -l | Print the current mounts. |
Propagation flags #
Each of these sets the propagation type of the mount at the target. The --make-r* (and r-prefixed -o) forms apply recursively to the whole subtree; the recursive form is all-or-nothing (it either applies to the entire subtree or to none of it).
| Flag | -o token | Recursive flag | Recursive -o token | Meaning |
|---|---|---|---|---|
--make-shared | shared | --make-rshared | rshared | Events propagate to and from peer mounts. |
--make-slave | slave | --make-rslave | rslave | Events propagate in from the master but not back out. |
--make-private | private | --make-rprivate | rprivate | No propagation either way. |
--make-unbindable | unbindable | --make-runbindable | runbindable | Private, and cannot be bind-mounted. |
A freshly created mount, bind or move is private by default unless its destination's parent is shared, in which case it joins that peer group — the standard kernel rule.
Source specification #
| Form | Resolution |
|---|---|
Device path (/dev/sda1) | Used directly. |
-L LABEL / LABEL=, -U UUID / UUID=, PARTLABEL=, PARTUUID= | Resolved to a device via libblkid. No match is a mount failure (exit 32). |
| A directory or regular file | Used directly (a bind source or target; a file may be a bind target). |
A pseudo source (tmpfs, proc, sysfs, none, …) | Passed through; -t is required because it cannot be probed. |
| An image file | Attached through a loop device (see Loop devices). |
Filesystem type #
-t TYPE names the type explicitly. -t auto, or omitting -t entirely, asks libblkid to safely probe the source; an ambiguous or multiply-signed device is refused (exit 32) rather than guessed at. X-mount.auto-fstypes=LIST constrains the probe candidates. Type lists and no<type> negation are not accepted in the mounting path (they remain meaningful only as a listing filter).
The -o option language #
-o takes a comma-separated list of key or key=value tokens and is repeatable (all occurrences are joined). A key="..." double-quoted value protects embedded commas; the key=value split is on the first =. Every token is sorted into one of the following categories.
Per-mount attributes #
Applied to the mount itself. Each accepts its negation; ro/rw additionally accept a =vfs / =fs / =recursive scope qualifier (bare is =vfs, non-recursive; =fs targets the superblock read-only flag; =recursive applies to the whole subtree).
| Option | Effect |
|---|---|
ro / rw | Read-only / read-write. |
suid / nosuid | Honour / ignore set-user-ID bits. |
dev / nodev | Allow / disallow device nodes. |
exec / noexec | Allow / disallow execution. |
atime / noatime | Update / never update access times. |
relatime / norelatime | Relative-atime updates on or off. |
strictatime / nostrictatime | Strict-atime updates on or off. |
diratime / nodiratime | Directory access-time updates on or off. |
nosymfollow | Do not follow symlinks on this mount. There is no positive symfollow token — the attribute is cleared only via a remount mask. |
The atime tokens share a single mode field; the last one specified wins.
Superblock flags #
| Option | Effect |
|---|---|
sync / async | Synchronous / asynchronous writes. |
dirsync | Synchronous directory updates. |
lazytime / nolazytime | Lazy on-disk timestamp updates on or off. |
iversion / noiversion | Inode version counting on or off. |
silent / loud | Suppress or emit certain kernel messages. |
mand / nomand | Obsolete (removed from the kernel). Accepted and ignored with a note under -v. |
Filesystem-specific parameters #
Any token not recognised above is forwarded verbatim to the filesystem: key=value as a string parameter, a bare key as a flag. There is no built-in allow-list and no length limit; a rejection by the filesystem is reported with the offending key and the kernel's message.
For example, StrataFS takes its precedence-ordered directory stack through
strata=:
See StrataFS for the stack flags, merge
and write-routing model, and the stratafs inspection command.
Userspace-only tokens #
These never reach the filesystem. They include the meta-verbs remount, bind, rbind, move; the loop controls loop / loop=/dev/loopN, offset=, sizelimit= (numeric values accept K/M/G/T and KiB/MiB/… suffixes); the propagation tokens listed above; and defaults, which expands to rw,suid,dev,exec,async (later tokens override it).
Functional X-mount.* options #
| Option | Effect |
|---|---|
X-mount.mkdir[=mode] | Create the target directory if missing (default mode 0755; the alias of -m). |
X-mount.subdir=DIR | Attach subdirectory DIR of a freshly mounted filesystem at the target. Effective only for a new-instance mount; silently ignored (noted under -v) for bind/move/remount/propagation. |
X-mount.noloop | Suppress the implicit loop device for a regular-file source. |
X-mount.auto-fstypes=LIST | Constrain the -t auto probe to these types. |
X-mount.nocanonicalize[=source|target] | The -o form of -c; with =source or =target it disables canonicalisation for just that path. |
X-mount.idmap and X-mount.owner / group / mode are not supported and are rejected with a usage error: Peios ownership is SID/security-descriptor based, so the fix is to add a SID/ACE to the descriptor rather than remap or chown.
KACS mount policy #
This is the genuinely Peios-specific part of mount. A mount policy is a per-superblock setting that governs how FACS treats the filesystem; the full model is described in Mount policies and its detail pages. mount can set that policy at attach time:
| Option | Policy class |
|---|---|
-o policy=deny-missing | facs_deny_missing — a file with no SD is unreachable. |
-o policy=synth-ephemeral | facs_synthesize_ephemeral — a missing SD is synthesised in memory only. |
-o policy=synth-persist | facs_synthesize_persistent — a missing SD is synthesised and written back. |
--synth-sddl SDDL | Provide the mount-level SD template used during synthesis (only valid with a synth-* policy). |
policy=unmanaged is not user-settable; only the kernel sets the unmanaged class, for its own pseudo-filesystems. policy= is valid only on a new mount of a real filesystem — combining it with bind/move/remount/propagation, or with list mode, is a usage error. See Policy classes for what each class does and SD storage by filesystem for how the SD is physically stored.
The policy is applied to the detached filesystem before it is attached: if setting it fails, nothing is ever published with an unintended policy (no rollback needed). Because setting a mount policy is a SeTcbPrivilege-gated operation (see Managing mounts), a caller without that privilege gets a clean EPERM (exit 1) and no mount. --synth-sddl is validated client-side first — it must be well-formed SDDL and must include an owner.
Peios applies no coarse uid==0 check anywhere: the mount applet is not installed set-user-ID, and every privileged action is authorised per-operation by KACS.
Remounting #
-o remount changes the options of an existing mount without detaching it. Peios does not perform the util-linux "read the old flags and re-supply them" dance — the fd-based API applies deltas rather than resetting, so each remount touches only what you name. Per-mount attributes (ro/rw, nosuid, atime, …) are changed via mount_setattr; superblock flags, filesystem parameters and ro=fs/rw=fs go through the superblock reconfigure path. =recursive remounts the whole subtree, all-or-nothing.
A bind mount shares its source's superblock, so any superblock-level option on a bind remount (a Category-B flag, a filesystem parameter, or ro=fs/rw=fs) is refused as a usage error rather than silently mutating the shared superblock for every mount of that filesystem. Only per-mount VFS attributes are valid on a bind remount.
Loop devices #
A regular-file source is backed by a loop device automatically when the type is unspecified or the filesystem is recognised by libblkid; X-mount.noloop suppresses this. -o loop forces auto-allocation of a free device, loop=/dev/loopN names one, and offset= / sizelimit= select a region of the file (they imply loop and are an error against a real block device). A backing file already attached at the same offset and size is reused rather than doubly attached, to avoid corruption. Loops created by mount are auto-cleared by the kernel on unmount, so they do not leak; umount -d force-clears the rest (see umount).
Other flags #
| Flag | Effect |
|---|---|
-t, --types TYPE | Filesystem type, or auto. |
-o, --options LIST | Mount options (above); repeatable. |
-r, --ro, --read-only | Mount read-only (-o ro). |
-w, --rw, --read-write | Mount read-write and forbid the automatic read-only fallback on a write-protected device (-o rw). |
--source SRC / --target DIR | Name the operands explicitly. |
--target-prefix DIR | Prepend DIR/ to the target. |
-B, --bind / -R, --rbind / -M, --move / --beneath | The structural verbs. |
--make-*, --make-r* | Propagation changes. |
--exclusive | Force a unique superblock instance (no reuse). Meaningful only for multi-instance filesystems (e.g. tmpfs) or read-only block mounts; on an already-mounted writable block device it fails with EBUSY (exit 32). |
-m, --mkdir[=MODE] | Create the target directory if missing (default mode 0755). |
-L, --label LABEL / -U, --uuid UUID | Select the source by filesystem label / UUID. |
-c, --no-canonicalize | Do not canonicalise paths. |
-f, --fake | Dry run: parse, resolve and plan everything but skip the mount syscalls and the policy step. |
-v, --verbose | Narrate resolved values and each syscall; drain the kernel fs_context log. Repeatable but -vv is the same as -v. |
-l, --show-labels | In list mode, append each filesystem's label. |
--onlyonce | Skip the mount if it is already present (a driver-aware check against live mount state, not a naive string match). |
-N, --namespace NS | Operate inside mount namespace NS (a PID, an ns file path, or a named namespace). Source resolution happens in the caller's namespace; the mount lands in the target namespace. |
-i, --internal-only | Do not invoke a mount.<type> helper. |
-n, --no-mtab | Accepted and ignored (Peios has no mtab). |
--synth-sddl SDDL | KACS synth-policy template SD (above). |
-h, --help / -V, --version | Standard. |
No external mount helpers ship in this version, so a network or FUSE type fails with "no helper for type" (exit 1) — a clean deferral, not a crash.
List mode #
With no operands, mount prints one line per mount, read from /proc/self/mountinfo:
SOURCE on TARGET type FSTYPE (OPTIONS)
mount -t TYPE with no operands filters the list by filesystem type instead of mounting; here type lists (-t ext4,xfs) and no<type> negation (-t nosysfs) are honoured. -l appends [LABEL] to each line when a label is known.
Details of the rendering: the option field is the positional VFS-then-superblock merge with a coalesced ro/rw, not sorted; mountinfo octal escapes are decoded; control characters in the mount point become ?; and the source is shown as the kernel recorded it, with /dev/loopN resolved to its backing file and /dev/dm-N to /dev/mapper/<name>. The listing shows only mounts the caller's token may observe; a partial or empty view is correct, not an error, and the paths of filtered-out parents are never reconstructed.
For a device-oriented view of what is available to mount, use lsblk.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Incorrect invocation or a permission denial — bad flags, mutually-exclusive verbs, a lone operand, an invalid policy= or --synth-sddl, an authorisation failure (EPERM/EACCES), an embedded NUL, or "no helper for type". |
2 | System error (out of memory, no free loop device, cannot fork). |
4 | Internal error (an invariant failure). |
8 | Interrupted (SIGINT), after signal-safe cleanup. |
32 | Mount failure — a syscall in the flow failed, a label/UUID had no match, a probe was undetermined, or a --beneath/move/--exclusive kernel refusal. |
126 | An external mount.<type> helper was found but failed to execute (moot until a helper ships). |
Code 16 (mtab) is never produced. Code 64 (some succeeded, some failed) only arises when several source arguments are given and at least one succeeds while another fails.
umount
Peios / Using Peios / Peiosutils / Disks and mounts
umount detaches a filesystem from the Peios mount tree. It is the counterpart of mount and, like it, reads live mount state from the kernel (/proc/self/mountinfo) rather than any /etc/mtab — Peios has none. Each operand is resolved against that live state and unmounted with umount2(2).
umount [-lfRA] [-dr] TARGET|SOURCE...
Operands and how they are resolved #
Each operand is either a mount point or a source:
- If the (canonicalised) operand is itself a mount point in the current mount table, it is unmounted directly. This is always unambiguous.
- Otherwise the operand is treated as a source and matched against the sources in the mount table. If it is mounted in exactly one place, that place is unmounted. If it is mounted in several places,
umountrefuses with an error naming the candidates — resolve it by naming a specific mount point, or use-Ato unmount all of them.
If an operand matches nothing, it is "not mounted": normally an error (exit 32), but -g makes that a success and -q suppresses the message.
Several operands may be given in one invocation, and options may be interspersed with them (umount /a -f /b). Paths are handled as opaque bytes; an embedded NUL is a usage error. By default each operand is canonicalised; -c disables that and additionally selects UMOUNT_NOFOLLOW so the kernel does not follow a symlink in the final component.
Recursive and all-targets unmounts #
Two flags expand a single operand into multiple unmounts. Within one operand the expansion stops on the first failure.
-R/--recursiveunmounts the target and everything mounted underneath it, including any over-mount stack at a single point, ordered deepest-first.-A/--all-targetsunmounts every mount point of the given source in the current mount namespace. This is the live-mountinfo "unmount everywhere" complement to source resolution; it is not an fstab feature.-A -Rtogether compose: for each mount point of the source, recurse underneath it.
-R and -r are mutually exclusive (combining them is a usage error, exit 1).
Flags #
| Flag | Effect |
|---|---|
-l, --lazy | Detach the filesystem now and clean up references later (MNT_DETACH). |
-f, --force | Force the unmount (MNT_FORCE), e.g. for an unreachable server. |
-R, --recursive | Unmount the target and everything under it (see above). |
-A, --all-targets | Unmount every mount point of the given source (see above). |
-d, --detach-loop | After unmounting, free the backing loop device. Best-effort and verified: it clears the device only if it still backs the mount just removed, so a recycled loop number is never clobbered. Usually redundant, since mount-created loops auto-clear on unmount. |
-r, --read-only | If the unmount fails, remount the filesystem read-only instead (via mount_setattr). Mutually exclusive with -R. |
-g, --graceful | Exit 0 when the target is absent or not mounted (rather than failing). Applied unconditionally. |
-q, --quiet | Suppress "not mounted" messages. |
-c, --no-canonicalize | Do not canonicalise paths; selects UMOUNT_NOFOLLOW. Applied unconditionally. |
-v, --verbose | Say what is being unmounted. Repeatable. |
-N, --namespace NS | Enter mount namespace NS (a PID, an ns file path, or a named namespace) before reading its mount table and unmounting. All work happens inside that namespace. |
-n, --no-mtab | Accepted and ignored (no mtab on Peios). |
-i, --internal-only | Do not invoke a umount.<type> helper (none ship). |
--fake | Dry run: resolve operands and report, but skip the unmount syscalls. |
-h, --help / -V, --version | Standard. |
The umount2 flag mapping is direct: -l → MNT_DETACH, -f → MNT_FORCE, -c (or a symlinked final component) → UMOUNT_NOFOLLOW. MNT_EXPIRE is deliberately not exposed, matching util-linux.
On privilege #
As with mount, there is no coarse uid==0 check: the applet is not set-user-ID and every unmount is authorised per-operation by KACS. Where util-linux silently suppresses an option for non-root users (-c, and -g's effectiveness), Peios applies it unconditionally.
Exit status #
| Code | Meaning |
|---|---|
0 | Success (or a -g no-op on an absent target). |
1 | Incorrect invocation or a permission denial — including -R with -r, a missing operand, an ambiguous source, an embedded NUL, or an authorisation failure. |
2 | System error (e.g. cannot read the mount table). |
8 | Interrupted (SIGINT). |
32 | Unmount failed, or the target is not mounted (unless -g). |
64 | Several source/target arguments were given and at least one succeeded while another failed. |
126 | An external umount.<type> helper was found but failed to execute (moot until a helper ships). |
A single -R / -A / -A -R invocation stops on the first failure and yields 32; the aggregate 64 only appears across multiple top-level arguments. To see what is currently mounted before unmounting, use mount with no arguments or lsblk.
lsblk
Peios / Using Peios / Peiosutils / Disks and mounts
lsblk lists the block devices on the system and their relationships — disks, their partitions, and the device-mapper and loop devices layered on top. It is the device-oriented companion to mount: where mount with no arguments shows what is mounted, lsblk shows what is available to mount and what filesystem sits on each device.
lsblk [options] [DEVICE...]
By default it prints a tree, one row per device with children indented beneath their parent. Given one or more DEVICE operands (a name like sda, a full path like /dev/sda, or anything resolving to a device node), it re-roots the output at those devices.
Where the data comes from #
Each column is sourced from one of four places, and any that cannot be read degrades to an empty cell rather than aborting the run:
- sysfs (
/sys/block) — the device tree, sizes, and the topology/hardware columns. Peios leaves/sys/blockunpatched, so this is the standard no-udev path. - libblkid — filesystem identity:
FSTYPE,FSVER,UUID,LABEL, and the partition-table columns. libblkid is opened at runtime. - The device node's security descriptor —
OWNERandMODE, read the same wayls -lreads them. - The
/dev/disk/by-*symlink farm —ID-LINK. Populated by the device manager once it has run; there is deliberately no/run/udev/dataparser, so the column reads the links themselves and is empty in the initramfs, where no device manager runs.
Output modes #
The mode selects how the rows are formatted; it does not change which columns are shown.
| Flag | Mode |
|---|---|
| (default) | Indented tree. |
-l, --list | Flat list — the same columns, no tree glyphs. |
-J, --json | JSON. Flag columns render as bare booleans and MOUNTPOINTS as an array; children nest under a children key. |
-P, --pairs | KEY="value" pairs, one device per line. |
-r, --raw | Raw, space-separated. Values that could contain a space or control character are hex-escaped (\xNN) so fields stay parseable. |
-T, --tree[=COLUMN] | Force tree output even alongside -l, optionally attaching the tree glyphs to COLUMN instead of NAME. |
Columns #
With no column flag, lsblk prints the default set: NAME, MAJ:MIN, RM, SIZE, RO, TYPE, MOUNTPOINTS. Three flags swap in a preset, and -o names an explicit list (which wins over all of them):
| Flag | Column set |
|---|---|
-o, --output LIST | Exactly the comma-separated columns named (case-insensitive; an unknown name is a usage error). |
-O, --output-all | Every available column. |
-f, --fs | Filesystem view: NAME, FSTYPE, FSVER, LABEL, UUID, MOUNTPOINTS. |
-m, --perms | Permissions view: NAME, SIZE, OWNER, MODE. |
The available columns are NAME, KNAME, PATH, MAJ:MIN, FSTYPE, FSVER, LABEL, UUID, PTUUID, PTTYPE, PARTTYPE, PARTLABEL, PARTUUID, MOUNTPOINT, MOUNTPOINTS, SIZE, RO, RM, HOTPLUG, TYPE, OWNER, MODE, MODEL, VENDOR, REV, SERIAL, TRAN, HCTL, ALIGNMENT, MIN-IO, OPT-IO, PHY-SEC, LOG-SEC, STATE, ROTA, SCHED, PKNAME, and ID-LINK.
The OWNER and MODE columns #
OWNER and MODE describe the device node, not the filesystem on it, and they mirror ls -l exactly — there is no GROUP column and no POSIX permission bits, because access to a device node is governed by its security descriptor, not by a mode. OWNER is the owner SID (S-1-…); MODE is a three-character [type][x][+]: the device-type character (b for a block device, c for a character device), an x slot (never set for a block device), and a + when the DACL is inheritance-protected. When the SD cannot be read — for instance on a non-Peios host with no KACS syscalls — OWNER shows ? and MODE degrades honestly.
Filtering, sorting and shaping #
| Flag | Effect |
|---|---|
-a, --all | Include empty (zero-size) devices, which are hidden by default. |
-d, --nodeps | Do not print a device's holders or slaves (drop the children). |
-I, --include LIST | Show only devices with these major numbers (comma-separated). |
-e, --exclude LIST | Exclude devices by major number. The default is 1 (RAM disks); an explicit -e replaces that default, and -a clears exclusions entirely. |
-s, --inverse | Print dependencies in inverse order (holders above the devices they depend on). |
-M, --merge | Collapse a subtree shared by several parents (a multipath device) to a single occurrence. |
-E, --dedup COLUMN | Drop rows whose COLUMN value duplicates an earlier one. |
-x, --sort COLUMN | Sort siblings by COLUMN (numeric columns sort by value, not text). |
Formatting #
| Flag | Effect |
|---|---|
-b, --bytes | Print SIZE as an exact byte count instead of a human-readable value. |
-p, --paths | Print full /dev paths in the NAME column. |
-n, --noheadings | Omit the header row. |
-i, --ascii | Draw the tree with ASCII characters instead of box-drawing glyphs. |
-y, --shell | Render column keys shell-safe (MAJ:MIN becomes MAJ_MIN). |
-w, --width NUM | Truncate each table row to NUM columns wide. |
--sysroot DIR | Read sysfs, the mount table and /dev from DIR instead of / (chiefly for testing against a fixture). |
-h, --help / -V, --version | Standard. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Incorrect invocation (an unknown column, a malformed major-number or width value) or a failed run (for example, /sys/block is unreadable). |
lsblk uses this single non-zero code, matching util-linux. A per-device read failure is not fatal: it degrades the affected columns to empty or ? and the run continues. A broken output pipe (piping into head, for instance) is treated as success.
mkirf
Peios / Using Peios / Peiosutils / Boot images
mkirf compiles an initramfs source tree into an initramfs image. The source tree is an ordinary directory — on a running Peios system that directory is /boot/initramfs/ — whose contents map 1:1 onto / inside the initramfs. The image is a single deterministic, gzip-compressed newc cpio archive that the bootloader loads into memory alongside the kernel.
mkirf [--watch] [--debounce SECS] [--exclude GLOB]... <src-dir> <out-file>
This page is the command reference. For what the initramfs stage is — prelude, the /boot/initramfs/ layout, and the handoff to the real root — see The initramfs stage; for how hooks declare their order, see Boot hooks. mkirf reads <src-dir> and writes <out-file>; it never writes back into the source tree, so the directory you inspect is always exactly what packages and you have put there.
What a build does #
Given a source tree, one build runs a fixed sequence:
-
Validate the layout.
<src-dir>must be a directory, must carry an executableinit(the initramfs PID 1 — a symlink is fine as long as it resolves within the tree), and must not already contain a hook sequence —hooks.seqorsystem/prelude/hooks.seq.<n>— whichmkirfgenerates. A missing or non-executableinit, or a stray sequence file, stops the build. -
Walk the tree. Every object beneath
<src-dir>becomes a cpio entry — regular files (with their executable bit preserved), directories, symlinks (target stored verbatim), character/block device nodes, and FIFOs. Sockets, and any other unsupported type, are rejected. Entries are sorted inLC_ALL=Cbyte order, which places every directory before its descendants — the order the kernel's unpacker requires. -
Resolve the hook order. Hooks are the regular files directly under
<src-dir>/usr/libexec/prelude/hooks.d/(packaged) or<src-dir>/lcl/libexec/prelude/hooks.d/(operator-placed), with the operator's copy winning when both hold the same file name. The legacy<src-dir>/hooks/is still read, and each hook found there is reported with the directory it should move to.mkirfparses each hook's# /// hookmetadata block, topologically sorts the resulting capability DAG, and bakes the execution order into generated manifests injected into the image at/system/prelude/hooks.seq.<n>. A missinghooks/directory simply means no hooks — the manifests are still written, empty, because prelude treats a missing sequence as a broken image rather than as "nothing to run".The version is in the file name, and
mkirfwrites every version it can express faithfully — currentlyhooks.seq.1(the flat resolved order) andhooks.seq.2(that order plus each hook's declarations).mkirfships inpeiosutilsand prelude ships in its own package, so an image can pair a newer writer with an older reader; naming the versions separately lets one image satisfy both, which a single file carrying an in-band version cannot. A version is dropped only when a future format carries something it can no longer project honestly.They live under
/system— the tier for derived content, alongsidesystem/retc— rather than/usr, which is the vendor tier no package ships these into, or/var/state, which is for mutable state rather than immutable build output. See Validation and errors below. -
Write atomically. The archive is written to a sibling temp file and
renamed into place, so a failed run never leaves a half-written image behind. On successmkirfprints the path, entry count, and byte size to stderr.
Early (pre-decompression) segments #
A reserved <src-dir>/++/ directory, if present, holds early initramfs segments — content the kernel consumes before it decompresses the main archive, namely CPU microcode and ACPI table overrides. Each immediate child of ++/ is one segment whose own contents map onto the cpio root (++/microcode/kernel/x86/microcode/GenuineIntel.bin becomes kernel/x86/microcode/GenuineIntel.bin); the segment directory name is a human label and never appears in the archive. Every ++/ entry must itself be a directory. The segments are merged into one uncompressed cpio prepended ahead of the gzip-compressed main archive. With no ++/ directory the output is exactly a single gzip member. mkirf stays format-agnostic here: it knows "early segments", never "microcode".
The deterministic-build guarantee #
The same source tree always compiles to the same image, byte for byte. This is what makes "did anything actually change?" a meaningful question and underpins later work such as signed boot artifacts. Determinism comes from normalising everything that would otherwise vary between builds:
- Ownership, inode numbers, and timestamps are all emitted as zero.
- Permission bits are normalised. The source tree is authoritative for file type and, for regular files, executability — nothing else. Read/write permission bits are not Peios's access mechanism, so they are flattened to a constant: directories
0755, symlinks0777, FIFOs and device nodes0644, regular files0755if executable and0644otherwise. - The gzip member carries mtime 0 and no embedded filename (the
gzip -nequivalent), at compression level 9. - The early region is byte-stable, with file payloads padded to a 16-byte boundary by widening the preceding entry's name field with NUL bytes.
Validation and errors #
mkirf will not produce an image from a source tree that cannot boot. A misconfiguration is caught when the image is built — on a running system where the message is easy to read — rather than as a mystery failure at the next boot. The checks:
- Layout.
<src-dir>is not a directory; noinit, orinitis not a regular file, or is not executable; a pre-existing hook sequence; a++entry that is not a directory; two++segments defining the same file. (Exit 1.) - Hooks. A malformed
# /// hookmetadata block (unclosed, a non-comment line inside it, a duplicate or unknown key, a badly-formed array), a dependency cycle, arequiresthat nothing supplies, or a capability that is both provided and contributed to (a capability is either a set of alternatives or a set of contributors, never both). Anafternaming a capability nothing supplies is not an error — that is what distinguishes it fromrequires. (Exit 1.) A hook with no metadata block at all is not an error — it is the escape hatch: it is scheduled last, in name order, andmkirfprints a warning so a forgotten block is visible. - I/O. Any filesystem read, write, or compression failure. (Exit 1.)
- Usage. A semantic invocation error clap cannot express — currently,
--watchwith<out-file>inside<src-dir>(see below). (Exit 2.)
Watch mode #
With --watch, mkirf stays resident: it builds once up front (so the watch never begins from a stale archive), then watches <src-dir> recursively and rebuilds after every change, debounced. This is what lets /boot/initramfs/ behave like an ordinary part of the filesystem — edit a hook and the initramfs is current again — rather than a build artifact an administrator has to remember to regenerate.
Watch mode is a foreground loop that runs until killed; supervising and restarting it is a service manager's job, not mkirf's. A rebuild that fails (say, a hook edited into a dependency cycle) is logged but does not stop the watch, so fixing the offending file recovers on the next change.
<out-file> must not live inside <src-dir>: each rebuild would write the image back into the watched tree and retrigger the watch endlessly. mkirf refuses this invocation up front (exit 2). The --debounce window (default 5 seconds) is the settle time — mkirf waits for the tree to be quiet for that long before rebuilding, so a burst of edits produces one rebuild rather than many.
Options #
| Option | Effect |
|---|---|
--watch | Stay resident and rebuild on every change to <src-dir>, after an initial build. Runs until killed. |
--debounce SECS | With --watch, the settle time before a rebuild. Default 5. A non-numeric value is a usage error. |
--exclude GLOB | Exclude paths (relative to <src-dir>) matching GLOB from the image. Repeatable. * and ? stay within a path segment; ** crosses separators. A matched directory is pruned with its whole subtree. A malformed glob is a usage error. |
The two positionals are both required:
| Positional | Meaning |
|---|---|
<src-dir> | The source tree; its contents map onto / in the initramfs. |
<out-file> | The output cpio.gz archive. |
Exit status #
| Code | Meaning |
|---|---|
0 | The image was built (or --help/--version was printed). |
1 | An operational failure — an invalid layout, an unresolvable hook set, or an I/O/compression error. |
2 | A usage error — a bad option, a missing positional, or --watch with <out-file> inside <src-dir>. |
See also #
- The initramfs stage — what
mkirf's output is for. - Boot hooks — the
# /// hookmetadata block and how order is resolved. - mkuki — wraps
mkirf's image, together with a kernel and command line, into a bootable UEFI unified kernel image.
mkuki
Peios / Using Peios / Peiosutils / Boot images
mkuki builds a UEFI unified kernel image (UKI): it takes a PE/COFF EFI stub and appends the kernel, the initramfs, and the kernel command line to it as named PE sections, producing a single EFI binary that UEFI firmware boots directly. Where mkirf produces the initramfs image, mkuki is the step that wraps that image — together with a kernel and a command line — into one bootable artifact. The two are the two halves of Peios' Dynamic Boot system.
mkuki --kernel PATH --initramfs PATH (--cmdline TEXT | --cmdline-file PATH) --out PATH
[--stub PATH] [--watch] [--debounce SECS]
mkuki --stub-info
The UKI section layout #
A UKI is an ordinary PE/COFF EFI executable — the stub — with three extra sections appended, which the stub reads at boot to find its payloads:
| Section | Content | Source |
|---|---|---|
.cmdline | The kernel command line, NUL-terminated. | --cmdline or --cmdline-file |
.linux | The kernel image. | --kernel |
.initrd | The initramfs cpio image. | --initramfs |
mkuki appends them in exactly that order. Each new section is marked as initialised, read-only data. The tool works at the PE level directly: it parses the stub's DOS/PE headers and section table, computes correctly-aligned virtual addresses and file offsets for the new sections, appends their data, updates the section count and the SizeOfImage/SizeOfHeaders fields, and — if the stub's header has no spare room for three more section entries — grows the header region and relocates the existing sections' file pointers to make space. A stub that is not a valid MZ/PE image, or whose optional header is malformed, is rejected.
The command line #
The command line is taken either literally from --cmdline or read from the file named by --cmdline-file (exactly one is required). Either way, mkuki trims trailing newlines and carriage returns and appends a single NUL terminator. A command line containing an embedded NUL byte is rejected.
The stub #
--stub names the EFI stub to append to. With no --stub, mkuki uses a stub bundled into the binary (the systemd EFI stub). mkuki --stub-info prints that bundled stub's provenance and exits, so you can see exactly what the default is without building anything.
Output and atomicity #
mkuki creates any missing parent directories of --out, writes the assembled image to a sibling temp file, and renames it into place, so an interrupted or failed build never leaves a half-written UKI behind. On success it prints the output path and byte size to stderr. Each of the three payloads must be non-empty; an empty .cmdline, .linux, or .initrd fails the build.
Watch mode #
With --watch, mkuki stays resident: it builds once up front, then rebuilds the UKI whenever an input changes — so the boot image tracks a new kernel, a freshly-repacked initramfs (mkirf's half of Dynamic Boot), or an edited command-line file with no manual step. Like mkirf's watch mode, it is a foreground loop that runs until killed; supervising it is a service manager's job, and a rebuild that fails (for example, a kernel caught mid-copy) is logged rather than fatal, so fixing the input recovers on the next change.
The watched inputs are --kernel, --initramfs, and — only if it is a --cmdline-file, since a literal --cmdline is static — the command-line file. mkuki watches each input's parent directory (non-recursively), not the file itself: this survives the atomic temp-and-rename writes that mkirf and mkuki both perform (which appear as a directory event a stale single-file watch would miss) and catches a versioned kernel being swapped in /boot. Because the watch is non-recursive, a write to an --out path nested deeper under a watched directory does not retrigger it — but an --out sitting directly in a watched input directory would, so mkuki refuses that invocation. The --debounce window (default 5 seconds) is the settle time before a rebuild.
Options #
| Option | Effect |
|---|---|
--kernel PATH | The kernel image; becomes the .linux section. Required (except with --stub-info). |
--initramfs PATH | The initramfs cpio; becomes the .initrd section. Required (except with --stub-info). |
--cmdline TEXT | The kernel command line, given literally; becomes the .cmdline section. Mutually exclusive with --cmdline-file. |
--cmdline-file PATH | Read the kernel command line from PATH instead. Mutually exclusive with --cmdline. |
--out PATH | The output UKI path. Required (except with --stub-info). |
--stub PATH | The PE/COFF EFI stub to append sections to. Defaults to the bundled systemd stub. |
--stub-info | Print the bundled stub's provenance and exit. |
--watch | Stay resident and rebuild the UKI whenever --kernel, --initramfs, or --cmdline-file changes. Runs until killed. |
--debounce SECS | With --watch, the settle time before a rebuild. Default 5. |
Exactly one of --cmdline or --cmdline-file must be given: supplying both, or neither, is a usage error.
Exit status #
| Code | Meaning |
|---|---|
0 | The UKI was built (or --stub-info, --help, or --version was printed). |
1 | A build or watch failure — a missing or unreadable input, an invalid stub, an empty payload, or an I/O error. |
2 | A usage error — a bad or missing option, or an invalid --cmdline/--cmdline-file combination. |
See also #
- mkirf — builds the
.initrdpayloadmkukiwraps. - The initramfs stage — what the bundled initramfs does once firmware boots the UKI.
reg
Peios / Using Peios / Peiosutils / Registry tools
reg is the command-line interface to the live registry — the running LCS store, reached through the registry system calls. Where regman reads shipped documentation and tells you what a key means, reg talks to the kernel and reads or writes what a key is. It is the everyday tool for scripting configuration: querying effective values, writing to layers, masking and hiding, managing security descriptors, watching for change, and taking backups.
reg <command> [options] <key> [value] [data]
reg is the read/write half of the registry toolset and regman is the lookup half. The division is exact:
| To… | Use… |
|---|---|
| Read or change what a value is set to, on this machine, right now | reg |
| Look up what a value means — its type, default, valid range, when it applies | regman |
Consult regman first to learn the legal range for a knob, then use reg set to write a value inside it. reg never checks a value's meaning and regman never touches the live store; they are complementary.
How reg treats access and privilege #
Every reg operation goes through the kernel's access check against the single key it opens — the same access-control path as any other protected object, with no traversal check on the parent keys. reg performs no identity or membership checks of its own: it does not refuse a privileged operation up front. It attempts the operation and reports faithfully whatever the kernel returns. An operation that needs a privilege you lack (creating a link, a positive-precedence layer, a backup or restore, reading a SACL) simply fails with access denied (exit 3) rather than being blocked by the tool. A single reg command may legitimately span privilege tiers.
Addressing model #
Almost every subcommand shares one addressing scheme: a key path positional, and an optional value name positional. Understanding the split is most of understanding reg.
Key paths #
The key is a positional path argument. Both / and \ are accepted as separators — neither character is legal inside an LCS key component, so there is no ambiguity, and you may use whichever your shell quotes most comfortably:
reg get Machine/System/KMES
reg get 'Machine\System\KMES' # identical
A leading separator is optional and ignored (/Machine/X is the same as Machine/X). Paths are compared case-insensitively. The first component is the hive:
| Path | Meaning |
|---|---|
Machine\… | The machine hive — system-wide configuration. |
Users\<SID>\… | A specific principal's hive. |
CurrentUser\… | A kernel alias, rewritten to the caller's own Users\<SID>\ at the syscall boundary. |
On output, reg displays paths with a backslash by default. --sep=/ (or REG_SEP=/) switches display to forward slash for that invocation; this affects display only, never how a path is parsed.
The value-name positional #
The value name is a separate positional argument — it is never folded into the key path. This is deliberate: an LCS value name may itself contain / or \ (which a key component may not), so keeping them apart removes all ambiguity.
reg get Machine/System/KMES BufferCapacity
# └──── key path ─────┘ └── value ──┘
The presence or absence of the value argument selects what the command targets:
- No value argument ⇒ the command targets the key itself — its metadata, its values as a set, its security descriptor, its children.
- A value argument ⇒ the command targets that one named value.
- The default value (the empty-name value) is addressed by the literal token
@in the value-name position, mirroring.regconvention:reg get Machine\App @.
This split applies uniformly to get, set, del, mask, and unmask.
Value literals and types #
On set (and in a batch), the value's data is a single token whose registry type is either forced by a type: prefix or inferred from its shape. The registry value types and their literal syntax:
| Prefix | Registry type | Data syntax |
|---|---|---|
sz: | REG_SZ | UTF-8 string (rest of token, verbatim). |
expand: | REG_EXPAND_SZ | UTF-8 string, %VAR% left unexpanded on disk. |
dword: | REG_DWORD | 42 or 0x2A; must fit u32. |
dword-be: | REG_DWORD_BIG_ENDIAN | 42 or 0x2A; must fit u32. |
qword: | REG_QWORD | 42 or 0x2A; must fit u64. |
multi: | REG_MULTI_SZ | Comma-separated; \, escapes a literal comma. |
hex: / bin: | REG_BINARY | Hex bytes; :, -, and space separators are ignored. |
link: | REG_LINK | An absolute key path (a symlink target). |
none: | REG_NONE | No data (token must be empty after the prefix). |
When the substring before the first : is not a recognised keyword (for example http://host), the token is not treated as typed and inference applies:
| Token shape | Inferred type |
|---|---|
all decimal digits, fits u32 | REG_DWORD |
all decimal digits, fits u64 but not u32 | REG_QWORD |
0x… hex, ≤ 8 significant hex digits | REG_DWORD |
0x… hex, ≤ 16 significant hex digits | REG_QWORD |
| anything else (including empty) | REG_SZ |
Inference is intentionally broad, so any all-digit token becomes a number — a PIN, a zip code, or 007 becomes a REG_DWORD and loses its textual form. To force a string, prefix it with sz: (sz:007 stores the string 007; sz:dword:42 stores the literal string dword:42). Because the coercion is a known trap, reg set always echoes the resolved type on success, so a surprising conversion is never silent even under --quiet.
Global options #
Every subcommand accepts the following. Each behavioural toggle also has an environment-variable equivalent, so it can be set per-invocation (the flag) or once per shell or script (the variable). The flag wins over the variable, which wins over the built-in default.
| Option | Env var | Effect |
|---|---|---|
--json | REG_JSON=1 | Emit structured JSON instead of human text. watch emits JSON-lines. |
-v, --verbose | REG_VERBOSE=1 | More detailed output. |
-q, --quiet | — | Suppress non-essential output. (A surprising type coercion is still reported.) |
--sep=CHAR | REG_SEP | Path display separator: \ (default) or /. |
--help | — | Print help for the command and exit 0. |
--version | — | Print the version and exit 0. |
Mutating subcommands additionally accept:
| Option | Env var | Effect |
|---|---|---|
--layer NAME | REG_LAYER | Target layer for the write (default: the base layer). |
-y, --yes | REG_ASSUME_YES=1 | Skip the confirmation prompt on a destructive command. |
Destructive commands (del -r, restore, layer del) prompt for confirmation when standard input is a terminal, and auto-proceed when it is not — so interactive use is guarded while scripts are never blocked. Set -y/--yes or REG_ASSUME_YES=1 to skip the prompt explicitly.
Reading #
reg get <key> [value] #
Read the effective (resolved) value. With a value, prints that value's data; with no value, lists the key's effective values (the default @ value first, then named values sorted case-insensitively) — a shorthand for ls --values-only.
| Option | Effect |
|---|---|
-L, --layers | Annotate the value with the layer it resolved from and its sequence number. |
--raw | Write the value's raw bytes to standard output verbatim — for piping REG_BINARY data. |
--no-follow | Operate on a symlink key itself rather than following it to its target. |
The single-value default form prints data bare (no quotes; one element per line for REG_MULTI_SZ) so it pipes cleanly. -L shows the winning layer's provenance:
$ reg get Machine\System\KMES BufferCapacity
4194304
$ reg get Machine\App Theme -L
Theme = REG_SZ "dark" (layer: policy, seq 7)
Only the winning value is shown; the full shadowed stack beneath it is not yet exposed by any client ABI (the -L flag will render the whole stack unchanged once that primitive lands). See Layers for what "effective" resolves against.
reg ls <key> #
One-level listing of a key's immediate subkeys and values.
| Option | Effect |
|---|---|
-l, --long | Long form: for subkeys, child and value counts; for values, type and byte size. |
--keys-only | List subkeys only. |
--values-only | List values only. |
$ reg ls Machine\System\KMES -l
BufferCapacity = REG_QWORD 4194304 (8 bytes)
MaxEventSize = REG_DWORD 65536 (4 bytes)
reg tree <key> #
Recursively list the subkey tree rooted at key.
| Option | Effect |
|---|---|
--depth N | Limit recursion to N levels. |
--values | Include each node's values, not just its keys. |
$ reg tree Machine\System --depth 2
reg info <key> #
Print a key's metadata: leaf name, subkey and value counts, last-write time, hive generation, security-descriptor size, and the volatile and symlink flags.
| Option | Effect |
|---|---|
--no-follow | Inspect a symlink key itself rather than its target. |
$ reg info Machine\System\KMES
path Machine\System\KMES
name KMES
subkeys 0
values 4
last_write_ns 1717243800000000000
hive_generation 42
sd_size 164
volatile false
symlink false
Writing #
reg set <key> <value> <data> #
Create or update a value. The value name (or @) and the data token are both required. Writes are tagged with --layer (default: base). See value literals for the data syntax.
| Option | Effect |
|---|---|
--layer NAME | Write into layer NAME instead of the base layer. |
-p, --parents | Create any missing ancestor keys. |
--expected-seq N | Compare-and-swap guard: apply only if the value's current sequence is N; a mismatch exits 6 without writing. |
$ reg set Machine\App Build 4096
set Machine\App Build = REG_DWORD 4096 (layer: base)
$ reg set Machine\App Servers multi:alpha,beta --layer policy
--expected-seq supports lock-free read-modify-write: read the value with -L to learn its sequence, then write back with --expected-seq set to that number; if another writer changed it in between, your write fails cleanly instead of clobbering theirs.
reg new <key> #
Create a key with no values. Reports whether the key was created or already existed.
| Option | Effect |
|---|---|
--layer NAME | Create the key in layer NAME. |
-p, --parents | Create any missing ancestor keys. |
--volatile | Create a volatile (RAM-only) key that does not survive a reboot. |
$ reg new Machine\App\Cache --volatile
created Machine\App\Cache (layer: base)
reg del <key> [value] #
Delete a value, or a key. (Alias: delete.)
With a value, deletes this layer's entry for that value only — lower-layer entries resurface, because a per-layer delete is not a tombstone (use mask for that). With no value, deletes the key, which must be empty unless -r is given.
| Option | Effect |
|---|---|
--layer NAME | Delete from layer NAME. |
-r, --recursive | Delete the key and all its descendants (walks and removes children). |
-y, --yes | Skip the confirmation prompt (recursive deletes prompt on a terminal). |
$ reg del Machine\App Legacy
deleted value Legacy from Machine\App
$ reg del Machine\App\Temp -r
Recursively delete key Machine\App\Temp and all its contents? [y/N] y
deleted Machine\App\Temp (3 keys)
See Deleting keys and values for how a per-layer delete differs from a tombstone.
Masking and hiding #
These commands expose LCS's tombstone and hide primitives, which mask lower-precedence state rather than removing a layer's own entry — the distinction that makes layers revertible. The layer is chosen with --layer (default: base).
reg mask <key> [value] / reg unmask <key> [value] #
mask sets a tombstone: a per-value marker in the target layer that hides that value from all layers below it. unmask clears it.
| Option | Effect |
|---|---|
--layer NAME | The layer that carries the tombstone. |
--all | Blanket tombstone: mask all lower-layer values on the key (no value argument). |
A value is required unless --all is given.
$ reg mask Machine\App ApiKey --layer policy
set tombstone on Machine\App ApiKey (layer: policy)
$ reg mask Machine\App --all --layer policy
set blanket tombstone on Machine\App (layer: policy)
reg hide <key> / reg unhide <key> #
hide installs a HIDDEN path entry in the target layer, masking the key's existence in that layer and below. unhide removes that layer's path entry, letting the key reappear.
| Option | Effect |
|---|---|
--layer NAME | The layer that hides (or unhides) the key. |
$ reg hide Machine\App\Debug --layer policy
hid Machine\App\Debug (layer: policy)
Layers #
reg layer ls #
List layers with name, precedence, enabled state, and (informational) owner SID, ordered by descending precedence.
| Option | Effect |
|---|---|
-l, --long | Long form. |
$ reg layer ls
policy prec=100 enabled owner=S-1-5-32-544
base prec=0 enabled
reg layer new <name> #
Create a layer by writing its metadata key under Machine\System\Registry\Layers\.
| Option | Effect |
|---|---|
--precedence N | Precedence (higher wins). A precedence above 0 requires SeTcbPrivilege; the tool attempts it and reports any denial. |
--owner SID | Record an informational owner SID. |
--disabled | Create the layer disabled. |
$ reg layer new policy --precedence 100 --owner S-1-5-32-544
created layer policy (precedence 100, enabled)
reg layer set <name> #
Modify an existing layer's metadata.
| Option | Effect |
|---|---|
--precedence N | Change the precedence. |
--enable / --disable | Enable or disable the layer. |
--owner SID | Change the informational owner SID. |
reg layer del <name> #
Delete a layer — removes its metadata key; LCS tears down the layer's entries. Prompts for confirmation on a terminal.
$ reg layer del policy
Delete layer policy and all its entries? [y/N] y
deleted layer policy
Managing per-process private layer views is out of scope for reg; see private hives and layers.
Security descriptors #
reg sd <key> #
Show or set a key's security descriptor as SDDL, using the same codec as the sd tool, so its output is consistent. With no scope flags, the default is owner + group + DACL (a SACL requires the privileged ACCESS_SYSTEM_SECURITY right).
| Option | Effect |
|---|---|
--set SDDL | Apply the given SDDL. Without it, sd prints the current descriptor. |
--owner | Scope to the owner. |
--group | Scope to the group. |
--dacl | Scope to the DACL. |
--sacl | Scope to the SACL (needs ACCESS_SYSTEM_SECURITY). |
$ reg sd Machine\App
O:BAG:BAD:(A;;KA;;;BA)(A;;KR;;;WD)
$ reg sd Machine\App --set 'O:BAG:BAD:(A;;KA;;;BA)' --owner --dacl
set security descriptor on Machine\App
A security-descriptor change takes effect on future opens only (existing handles keep the access mask they were granted). Security changes are not layered and are not undone by layer removal — they mutate the key object directly. See Access control on keys.
Symlinks #
reg link <key> <target> #
Create a symlink key pointing at the absolute target key path. This creates a key with the immutable symlink flag and a default REG_LINK value holding the target. Requires KEY_CREATE_LINK and SeTcbPrivilege/Administrator; the tool attempts it and reports any denial.
| Option | Effect |
|---|---|
--layer NAME | Create the link key in layer NAME. |
$ reg link Machine\App\CurrentConfig Machine\App\Config\v3
linked Machine\App\CurrentConfig -> Machine\App\Config\v3 (layer: base)
get and info follow symlinks to their target by default; pass --no-follow to inspect the link key itself. See Advanced: registry links.
Watching #
reg watch <key> #
Arm a change watch and stream one event per change until interrupted (or until --count events). Each event faithfully means "something under the filter changed — re-read"; reg does not decode the change records' contents. With --json, emits one JSON object per line.
| Option | Effect |
|---|---|
--subtree | Watch descendant keys too, not just this key. |
--filter LIST | Comma-separated subset of value, subkey, sd (default: all). |
--count N | Exit after N events. |
$ reg watch Machine\System\KMES --subtree --filter value
changed: Machine\System\KMES
changed: Machine\System\KMES
See Watching for changes for why a service watches instead of polling.
Batch, export, backup, and restore #
Two paths move a subtree around: export/apply are the portable, reviewable path (a diffable text or JSON document); backup/restore are the exact, privileged path (an opaque kernel snapshot).
reg apply <file> #
Apply a batch of operations to one hive in a single transaction — all-or-nothing. All operations must target one hive (a cross-hive batch fails with exit 4). - reads standard input.
| Option | Effect |
|---|---|
--dir DIR | Apply every batch file in DIR (sorted), each as its own transaction. A missing or empty directory applies nothing and succeeds. |
--once-delete | Delete each batch file after it applies successfully (a drain). |
-y, --yes | Skip confirmation. |
The batch format is JSON (canonical and exact) or a line-oriented text format; apply auto-detects (a leading { or [ means JSON). Text-format input is not yet implemented — supply JSON, or generate one with reg export --json. Text export works for review.
$ reg export --json Machine\App - | reg apply -
applied 4 keys
reg export <key> [file] #
Dump a subtree to the batch format. Omit file (or pass -) to write to standard output. Human-readable text by default; --json emits the canonical exact form.
| Option | Effect |
|---|---|
--layer NAME | Export one layer's view (default: the effective state). |
$ reg export Machine\App app-config.reg
reg backup <key> <file> #
Write an opaque, exact, binary kernel snapshot of a key and its subtree to file. Requires SeBackupPrivilege.
$ reg backup Machine\System\KMES kmes.snap
backed up Machine\System\KMES -> kmes.snap
reg restore <key> <file> #
Replace a key and its entire subtree from a snapshot, in one transaction. Requires SeRestorePrivilege. Prompts for confirmation on a terminal.
$ reg restore Machine\System\KMES kmes.snap
Replace key Machine\System\KMES and its entire subtree from kmes.snap? [y/N] y
restored Machine\System\KMES from kmes.snap
See Backup and restore for when to reach for each path.
Output formats #
Default output is terse, human-readable, and grep-friendly. --json switches every command to structured output — a single self-contained JSON document, except watch, which emits JSON-lines (one object per event). In any value listing, the default (@) value is emitted first, then named values sorted case-insensitively.
Type formatting on read:
| Registry type | Human form | JSON form |
|---|---|---|
REG_SZ / REG_EXPAND_SZ | the string | {"type":"sz","data":"…"} |
REG_DWORD / REG_QWORD | decimal | {"type":"dword","data":42} |
REG_MULTI_SZ | one element per line | {"type":"multi","data":["a","b"]} |
REG_BINARY | hex | {"type":"binary","data":"deadbeef"} |
REG_LINK | the target path | {"type":"link","data":"…"} |
Exit status #
| Code | Meaning | Typical cause |
|---|---|---|
0 | Success. | — |
1 | Usage error. | Bad arguments; a required option missing. |
2 | Key or value not found. | ENOENT. |
3 | Access denied. | EACCES, EPERM — the kernel refused the operation. |
4 | Invalid specification. | Bad path, type, or literal; a cross-hive batch; a name too long (EINVAL, EXDEV, ENAMETOOLONG). |
5 | Syscall or source failure. | EIO, ETIMEDOUT, ENOSPC, ENOMEM. |
6 | Compare-and-swap conflict. | EAGAIN — an --expected-seq guard did not match; the value changed under you. |
A denial (3) reports the operation, the target, and, where relevant, the access that was required — in the style of the sd and token tools.
See also #
regman— the registry manual: what a key or value means, as opposed to what it is set to. Consult it before youreg set.- Keys, values, and types — the data model
regaddresses. - Layers — what "effective" resolves against, and what
--layer,mask, andhideoperate on. - Access control on keys — the check every
regoperation goes through.
regman
Peios / Using Peios / Peiosutils / Registry tools
Configuration, not storage established that the registry holds values but not their meaning: it keeps a type tag and some bytes, and never knows that BufferCapacity must be a power of two or what it is for. That knowledge has to live somewhere a person can look it up. It lives in regman, the registry's manual.
regman is the man of the registry. Give it a path and it tells you what a key or value actually is. It is the everyday tool for configuring a Peios system — before you touch a knob, regman is how you find out what it does and what changing it will cost.
regman, in one sentence #
regman <path> [value] documents what a registry key or value means — its type, default, valid values, when a change takes effect, and a prose description — drawn from shipped documentation, never from the live registry.
That last clause is the one to hold onto; there is a section on it below.
Looking up a value #
Give regman a key path and a value name and it prints a knob-card — an identity line, an aligned block of facts, and a description:
$ regman Machine\System\KMES BufferCapacity
Machine\System\KMES BufferCapacity documented by kmes
Type REG_QWORD
Default 4194304 (4 MB)
Valid 65536–268435456 bytes (64 KB–256 MB), power of two
Applies live — ring-buffer swap
Per-CPU ring buffer capacity, in bytes. Must be a power of two; values
that are not are treated as invalid and ignored.
Every registry value is a knob, and every knob has the same few facts worth knowing, so the card is uniform rather than free-form:
| Field | Tells you |
|---|---|
| Type | The value's registry type (REG_QWORD, REG_SZ, …). |
| Default | The value used when nothing is set. |
| Valid | The range, set, or constraint a sensible value must satisfy. |
| Applies | When a change takes effect — live, on restart, or on reboot. |
The Applies field is the one operators reach for most: it is the difference between "edit it and you're done" and "edit it and schedule a reboot". Only the fields that make sense for a given item are shown, and a knob that is being retired carries a prominent deprecation notice at the top of its card.
Looking up a key #
Give regman just a key — no value — and it does double duty as an index: the key's own description, then a list of every value documented under it, one summary line each.
$ regman Machine\System\KMES
Machine\System\KMES documented by kmes
The KMES event subsystem reads its tuning parameters from the values
under this key...
Values
BufferCapacity Per-CPU ring buffer capacity in bytes.
MaxEventSize Max total size of a userspace-emitted event.
MaxNestingDepth Max nesting depth for userspace event payloads.
MaxEmitRatePerProcess Max events per second a single process may emit.
So you can start at a subtree, see what is configurable there, and drill into any single value.
Searching, when you do not know the path #
If you don't know where a setting lives, regman -k searches names and summaries — the registry's apropos:
$ regman -k buffer
Machine\System\KMES BufferCapacity Per-CPU ring buffer capacity in bytes.
regman documents intent, not current state #
This is the boundary that matters most. regman tells you what a setting should be — what it means and which values are legal — not what it is currently set to on this machine. It reads shipped documentation, never the live registry; it does not talk to LCS at all. Ask regman about BufferCapacity and you learn it must be a power of two and defaults to 4 MB; you do not learn that this particular box has it set to 8 MB right now. Reading the current value is a separate, live query against the registry — that is reg's job. regman tells you a knob should be a power of two; reg get tells you what it is set to, and reg set changes it. Reach for regman to decide what to write, and reg to write it.
Put regman next to the other two things from Configuration, not storage and a clean division of labour appears:
| To learn… | Look at… |
|---|---|
| What a setting means, and what is valid | regman — the manual |
| What the value is set to | reg — a live query against the store |
| What the subsystem is actually running on | the event log |
regman is the one you consult first, and the only one that works before you have changed anything — because documentation ships with the software, not with the machine's state. It is the natural complement to reject-or-keep: regman tells you the valid range up front, so you set a good value the first time instead of writing one the owning subsystem will quietly refuse.
Where the documentation comes from #
regman has no built-in database of every setting. Each package ships its own documentation as files dropped into a well-known directory (/usr/share/regman/), one fragment per package documenting that package's whole configuration surface. Install a package and its registry documentation appears; remove the package and it goes away. The package that owns a set of keys is the package that documents them — the only arrangement that stays correct as the system changes.
A consequence worth knowing: regman can only describe settings that some installed package has documented. An undocumented key is invisible to it — regman is exactly as complete as the packages on the machine make it. If two packages happen to document the same item, regman shows both and flags the overlap rather than silently picking a winner.
(For the people who write that documentation, regman fmt and regman lint prepare and check fragments, and regman index keeps lookups fast on large corpora — the lookup is always correct without an index, which is purely an accelerator. These are packaging concerns rather than everyday operator ones.)
Where to go next #
For the idea regman exists to serve — why the registry holds values without holding their meaning, and what reject-or-keep means — read Configuration, not storage.
For the other half of the picture — reading what a value is actually set to, which is a live query against the store rather than a documentation lookup — read LCS and sources.
1.1 What an Event Is
Peios / Using Peios / Events / Introduction
An event is a record that something happened: an access was checked,
a service started, a package was installed, a descriptor was found
corrupt. Events are produced by the component that observed the thing,
and consumed by whatever is watching — usually eventd, sometimes a
tool reading the stream directly.
This book enumerates every event Peios emits, with the fields each one carries. It is a lookup, not an explanation. Where an event's meaning needs the mechanism behind it, the chapter links to the manual that describes that mechanism.
1.1.1 Two transports, not one #
Most events travel through KMES, the kernel message event stream: a per-CPU ring buffer that the kernel writes into and userspace reads from. Every KMES event is a binary header followed by a msgpack payload. Chapters 3 to 7 document KMES events.
Two things in this book are not KMES events, and are included because an operator looking for "what does Peios tell me" would otherwise miss them:
- eventd's synthetic events (§8) are written straight into a shard database and never touch KMES. They carry no header stamps.
- LCS watch records (§5.4) are binary records read from a key file descriptor. They are a notification mechanism, not an audit trail, and their format has nothing in common with a KMES event.
1.1.2 What is not here #
This book does not cover logs or metrics. Both reach eventd by a different path, are stored in different tables, and are not events. The eventd manual covers them.
Nor does it cover the query language for reading events back. That is PSPU §3, with the operator-facing view in the eventd manual.
1.2 The Envelope
Peios / Using Peios / Events / Introduction
Every KMES event is a packed binary header followed immediately by its msgpack payload, delivered as one contiguous byte sequence with no padding anywhere.
The emitter supplies only two things: the event type string and the payload. Everything else in the header is stamped by KMES itself, which is what makes the identity fields trustworthy — an emitter cannot forge them.
1.2.1 Fields KMES stamps #
| Field | Meaning |
|---|---|
timestamp | Wall clock at the moment KMES accepted the event, nanoseconds since the Unix epoch. |
sequence | The emitting CPU's per-boot counter. The first event on each CPU gets 1. |
cpu_id | The CPU whose ring buffer holds the event. |
origin_class | 0 for syscall emission, unconditionally. For kernel emission, the value the calling subsystem passed. |
| identity GUIDs | The effective, true and process token GUIDs of the task that caused the event. |
The identity stamps matter for reading this book: several events carry no caller in their payload at all, because the envelope already names one. StrataFS copy-up records are the clearest case — nothing in the payload names a token, and the caller is recovered from the header.
event_size, header_size and type_len are structural, computed by
KMES during construction.
1.2.2 Layout #
All fields before the event type string sit at fixed offsets. The type
string begins at offset 77, with its u16 length at offset 75, so the
header is exactly 77 + type_len bytes. The payload runs from
header_size to event_size, and the next event begins at
event_size from the start of the current one.
All multi-byte header integers are little-endian. The identity GUIDs are opaque 16-byte values.
The full field-by-field layout is normative in PSPK §2, the KMES event stream specification. This summary is enough to walk a stream; it is not enough to implement one.
1.2.3 Ordering #
timestamp is captured before sequence is assigned, so two events
with the same timestamp on the same CPU are ordered by sequence.
Across CPUs there is no global order. Two events on different CPUs with close timestamps may have been observed in either order.
1.3 Encoding Conventions
Peios / Using Peios / Events / Introduction
Every KMES payload in this book is a msgpack map with UTF-8 string keys. The key set is stable per event type.
1.3.1 Value representations #
The same conceptual types appear across many events and are always encoded the same way.
| Conceptual type | msgpack representation |
|---|---|
| SID | bin holding the binary SID, 8–68 bytes. |
| GUID | bin, exactly 16 bytes. |
| ACE | bin holding the binary ACE, copied from the descriptor. |
| Access mask | uint, 32-bit. |
| Boolean | bool. |
| Privilege name | string, UTF-8, e.g. SeBackupPrivilege. |
| Process ID | uint. |
| Path or name | string, UTF-8. |
| Timestamp | uint, unless an event's schema says otherwise. |
| Object context | bin or nil. An opaque caller-supplied blob; its contents are service-specific. |
Binary SIDs, GUIDs and ACEs are carried as bytes rather than as text because they are compared as bytes. A textual SID would have to be parsed back before it could be matched.
1.3.2 Event type strings use three different styles #
There is no single convention. What an event type looks like depends on which component emits it:
| Style | Emitters | Examples |
|---|---|---|
kebab-case | KACS | access-audit, logon-session-destroyed |
dotted.snake | peinit, peipkg, eventd | job.created, peipkg.repo-add, synthetic.config_change |
SCREAMING_SNAKE | StrataFS, LCS | STRATAFS_COPY_UP, LCS_BACKUP_START |
The dotted family is not internally consistent either: peipkg.repo-add
is dotted-then-kebab while synthetic.config_change is
dotted-then-snake.
This is recorded because a consumer matching event types has to know it, not because it is defended. Match the exact strings in this book rather than deriving one from a pattern.
1.4 Reading an Event
Peios / Using Peios / Events / Introduction
Four rules govern every consumer of this stream.
1.4.1 Ignore unknown keys #
Future versions may add fields to an event without changing the existing ones. A consumer that processes the keys it knows and ignores the rest keeps working across upgrades. A consumer that rejects unrecognised keys breaks on the first addition.
1.4.2 Do not rely on a key being absent #
A field that is optional today may become always-present later. Absence is not a signal.
1.4.3 Delivery is best-effort #
KMES is a ring buffer. The kernel writes; keeping up is the subscriber's problem.
- A subscriber that falls behind loses events. eventd notices and
records a
synthetic.gap(§8.1), which is how a gap becomes visible rather than silent. - There is no replay. An event missed is gone. Nothing can ask for it back.
- Buffers are per-subscriber. One slow reader does not affect another.
- Order is per-subscriber, not global.
For durable audit, read events from eventd's stores rather than from KMES directly. eventd drains its subscription continuously and persists what it reads; from that point the store is the record, not the ring.
1.4.4 Events are not authenticated #
Events are trusted because they came from the kernel through KMES, not because they are signed. Nothing in an event carries a signature.
Cryptographic non-repudiation is a userspace concern applied after events leave the kernel. If a deployment needs it, it is added on the far side of eventd, not here.
1.4.5 Versioning #
Event types are not versioned by a field. The schemas in this book are stable: fields may be added, but an existing field will not be renamed, retyped or removed under the same type string.
A change that would break compatibility changes the type string
instead — access-audit would become access-audit-v2 — so an existing
consumer keeps receiving the shape it understands and simply never sees
the new one. No type in this book has been versioned that way.
2.1 The Subject Record
Peios / Using Peios / Events / Common Records
The subject map identifies the effective token under which an
operation ran. It appears in every KACS event except
logon-session-destroyed.
For an event fired from an impersonating thread, the subject is the impersonation token, not the primary. For a non-impersonating thread the primary token is the effective one.
For continuous-audit (§3.2) the subject is the effective token at
the moment of the operation, not at the moment the handle was opened.
A process whose token changed since the open gets the current subject on
each subsequent operation.
2.1.1 Fields #
| Key | Type | Meaning |
|---|---|---|
user_sid | bin | The token's user SID. |
group_sids | array of bin | The token's group SIDs. |
group_attributes | array of uint | Per-group attribute bitmasks, parallel to group_sids. |
integrity_level | uint | The token's integrity RID — 0, 4096, 8192, 12288 or 16384. |
pip_type | uint | The calling process's PIP type. 0 None, 512 Protected, 1024 Isolated. |
pip_trust | uint | The calling process's PIP trust level. |
auth_id | uint | The LUID of the logon session the token belongs to. |
token_id | uint | The token's own LUID. |
impersonation_level | uint | 0–3. A primary token reports 0. |
projected_uid | uint | The Linux UID projection, for correlating with Linux-side audit data. |
Every field is always present.
auth_id is the join key to logon-session-destroyed (§3.5) and to
/sys/kernel/security/kacs/sessions. token_id correlates events from
one specific token.
2.1.2 The two parallel arrays #
group_sids[i] and group_attributes[i] describe the same group entry,
and the arrays are always the same length.
| Flag | Value | Meaning |
|---|---|---|
SE_GROUP_MANDATORY | 0x01 | Cannot be disabled. |
SE_GROUP_ENABLED_BY_DEFAULT | 0x02 | Enabled at creation. |
SE_GROUP_ENABLED | 0x04 | Currently enabled. |
SE_GROUP_OWNER | 0x08 | May act as owner for new objects. |
SE_GROUP_USE_FOR_DENY_ONLY | 0x10 | Matches deny ACEs only. |
SE_GROUP_INTEGRITY | 0x20 | Identifies an integrity SID. Present for ABI parity; MIC reads the token's integrity_level field, not this flag. |
SE_GROUP_INTEGRITY_ENABLED | 0x40 | Used with SE_GROUP_INTEGRITY. |
SE_GROUP_RESOURCE | 0x20000000 | A domain-local group from a resource domain. Metadata only. |
SE_GROUP_LOGON_ID | 0xC0000000 | The logon SID. Cannot be disabled. |
These are MS-DTYP's names, which PCDS uses. The headers declare the same
flags as KACS_SID_GROUP_*; the Peios Kernel TRM §3.A maps the two.
Reconstructing group membership from an event means applying the same
rule the access check applies: SE_GROUP_ENABLED set and
SE_GROUP_USE_FOR_DENY_ONLY clear, for allow-side matching.
2.1.3 What the subject deliberately omits #
Privileges, claims, the restricted-SID list, confinement state, and the default DACL are all absent. Each is unbounded, and an event that embedded them could grow without limit.
Code needing full token state queries the token directly with
KACS_IOC_QUERY, while it still exists. The subject record is for
correlation, not for reconstruction.
2.2 The Process Record
Peios / Using Peios / Events / Common Records
The process map identifies the process the event came from. It appears
in every KACS event except logon-session-destroyed, which has no
causing process.
| Key | Type | Meaning |
|---|---|---|
pid | uint | The process ID. |
name | string | The kernel's name for the process, typically the executable's basename. |
executable_path | string | The path resolved at exec, with symlinks already followed. |
Every field is always present. For continuous-audit this is the
operation-time process, not the one that opened the handle.
2.2.1 Correlating on it #
pid is reliable only in the short term. Process IDs are reused, so a
pid in a week-old record may name something unrelated. For durable
records, correlate on name and executable_path.
name is the kernel's internal name. It is not argv[0], and a process
that rewrote its argv is unaffected here.
2.2.2 No thread ID #
The record identifies a process, not a thread. Events that fire on one
specific thread still report only the process. Nothing in the current
event set carries a tid.
2.3 The Caller Summary
Peios / Using Peios / Events / Common Records
LCS uses its own identity submap, caller, rather than the subject
record. Six of its seven audit events carry it.
It exists separately because LCS's events are emitted from a different subsystem with a different bound on what it will serialise. The two records overlap but are not interchangeable, and a consumer handling both needs to read each on its own terms.
| Key | Meaning |
|---|---|
effective_token_guid | The token the operation ran under. |
true_token_guid | The underlying token, where impersonation is in play. |
process_guid | The calling process. |
user_sid | The effective token's user SID. |
authentication_id | The logon session LUID. |
token_id | The token's own LUID. |
token_type | Primary or impersonation. |
impersonation_level | 0 for a primary token. |
integrity_level | The token's integrity RID. |
Nine fields, and no more. Group lists, privilege arrays, claims and default DACLs are unbounded and are never included — the same reasoning that shapes the subject record (§2.1), applied independently.
2.3.1 Against the subject record #
subject | caller | |
|---|---|---|
| Emitted by | KACS | LCS |
| Identity by | SIDs | GUIDs, plus user_sid |
| Groups | group_sids with attributes | absent |
| PIP state | pip_type, pip_trust | absent |
| Linux projection | projected_uid | absent |
| Session join key | auth_id | authentication_id |
Note the session join key is spelled differently in each. Correlating a
KACS event with an LCS event on the same logon session means matching
subject.auth_id against caller.authentication_id.
3.1 access-audit
Peios / Using Peios / Events / Kernel Access Events
The most common event in the system. Fires at AccessCheck completion,
from the SACL audit walk, and from a token's audit_policy forcing an
audit that no ACE asked for.
Event type string: access-audit.
| Key | Type | Meaning |
|---|---|---|
subject | map | Subject record (§2.1). |
object_context | bin or nil | Caller-supplied opaque identifier for the object. nil if AccessCheck was not given one. |
requested_access | uint | The mask the caller requested, after generic mapping. |
granted_access | uint | The mask actually granted. |
success | bool | True when every requested bit is in granted_access. |
trigger | map | Why this event fired. Below. |
process | map | Process record (§2.2). |
Every field is always present.
3.1.1 The trigger record #
| Key | Type | Meaning |
|---|---|---|
kind | string | sacl or policy. |
ace | bin or nil | For kind = sacl, the matched ACE's bytes. For kind = policy, nil. |
kind = sacl means an audit ACE in the object's SACL — or in a central
access policy's SACL — matched this access, and ace carries the exact
ACE so a consumer can identify which rule fired.
kind = policy means nothing in any SACL asked for this. The audit
fired because the calling token's audit_policy carries
OBJECT_ACCESS_SUCCESS or OBJECT_ACCESS_FAILURE, which forces an
audit on every access that token makes.
The distinction matters when reading volume. A flood of kind = policy
events is a property of the token, and is fixed by changing the token's
policy. A flood of kind = sacl events is a property of the object, and
is fixed by changing its SACL.
3.1.2 One access can produce several events #
The event is per matching audit ACE, not per access. An access that
matches three audit ACEs produces three events, each with a different
ace in its trigger and otherwise identical.
3.1.3 Example #
A successful read where a SACL audit ACE matched:
{
"event_type": "access-audit",
"event_time": <timestamp>,
"subject": { ... },
"object_context": <bin>,
"requested_access": 0x00120089,
"granted_access": 0x00120089,
"success": true,
"trigger": { "kind": "sacl", "ace": <bin> },
"process": { ... }
}
0x00120089 is GENERIC_READ after mapping to file-specific bits.
Generic bits never survive into an event; the mask is always specific.
3.2 continuous-audit
Peios / Using Peios / Events / Kernel Access Events
Fires per operation on an already-open handle, when the operation's required access overlaps a continuous audit mask cached on that handle.
Where access-audit records the decision to open something,
continuous-audit records what was then done with it. The mask is
configured by SYSTEM_ALARM* ACEs at the access check that opened the
handle, and the event is fired afterwards by whatever kernel subsystem
enforces the operation — FACS for file handles.
Event type string: continuous-audit.
| Key | Type | Meaning |
|---|---|---|
subject | map | Subject record (§2.1), reflecting the operation-time effective token. |
object_context | bin or nil | Object identifier. May differ from the open-time context if the enforcement point keeps its own. |
operation | string | The operation name. FACS uses a file. prefix — file.read, file.write, file.fallocate. Other enforcement points use their own. |
requested_access | uint | The mask this specific operation needs. |
matched_access | uint | The subset of requested_access that overlapped the handle's continuous audit mask. |
granted_access | uint | The mask cached on the handle at open time. |
success | bool | Whether the operation itself succeeded. |
process | map | Process record (§2.2), operation-time. |
Every field is always present.
3.2.1 The three masks #
They are easy to confuse, and reading them together is the point of the event.
requested_accessis what the operation needs. A read needsFILE_READ_DATA; a write needsFILE_WRITE_DATA; anmmapwithPROT_EXECneedsFILE_EXECUTE.matched_accessisrequested_accessintersected with the handle's continuous audit mask. This is why the event exists — the bits that triggered it.granted_accessis what the handle was opened with. An operation can only succeed ifrequested_accessis a subset of it.
Read as a sentence: the operation needed these bits, of which these triggered audit, against a handle opened with these, and it succeeded or did not.
3.2.2 Why the subject is re-read #
A handle outlives the token that opened it. A process that changes its effective token — starting or stopping impersonation — keeps its handles, and every subsequent operation is audited under the token current at that moment.
An investigation that assumes the open-time identity for later operations will attribute them to the wrong principal.
3.2.3 Example #
A read on a file carrying an alarm ACE on FILE_READ_DATA:
{
"event_type": "continuous-audit",
"event_time": <timestamp>,
"subject": { ... },
"object_context": <bin>,
"operation": "file.read",
"requested_access": 0x00000001,
"matched_access": 0x00000001,
"granted_access": 0x00120089,
"success": true,
"process": { ... }
}
3.3 privilege-use
Peios / Using Peios / Events / Kernel Access Events
Fires at AccessCheck for a privilege that contributed bits to the
granted mask, when the token's audit_policy asks for it.
Event type string: privilege-use.
| Key | Type | Meaning |
|---|---|---|
subject | map | Subject record (§2.1). |
object_context | bin or nil | Object identifier from the access check. |
privilege | string | The canonical privilege name. |
requested_access | uint | The bits the caller requested that this privilege might address. |
granted_access | uint | The bits the privilege contributed, before later narrowing. |
surviving_access | uint | The subset of granted_access that reached the final granted mask. |
success | bool | True when surviving_access is non-empty. |
process | map | Process record (§2.2). |
Every field is always present.
3.3.1 Only five privileges can appear #
The privilege field carries a canonical name, and only five are
representable:
SeSecurityPrivilegeSeTakeOwnershipPrivilegeSeBackupPrivilegeSeRestorePrivilegeSeRelabelPrivilege
Any other bit fails the encoder closed rather than emitting an unnamed privilege. This is consistent with those being the only five that can influence an access check at all, and therefore the only five that can produce this event.
A consumer will never see a sixth name here. A privilege used for
something other than an access decision produces no privilege-use
event.
3.3.2 Success means it worked, not that it fired #
This is the field most often misread.
success = true — the privilege contributed bits and they survived to
the final grant. The privilege did useful work. Governed by the
PRIVILEGE_USE_SUCCESS audit policy.
success = false — the privilege contributed bits and a later layer
stripped them. The caller was in a confinement that does not permit it,
or a CAAP rule narrowed it out, or the caller was non-dominant under
PIP. Governed by PRIVILEGE_USE_FAILURE.
A success = false event is not a failed attempt to use a privilege.
It is a privilege that fired and was then overridden — which is usually
the more interesting record of the two, because it shows a boundary
doing its job.
A token can carry either policy bit, both, or neither, and each controls its own flavour independently.
3.3.3 Example #
A backup tool whose SeBackupPrivilege was stripped by confinement:
{
"event_type": "privilege-use",
"event_time": <timestamp>,
"subject": { ... },
"object_context": <bin>,
"privilege": "SeBackupPrivilege",
"requested_access": 0x00000001,
"granted_access": 0x00000001,
"surviving_access": 0x00000000,
"success": false,
"process": { ... }
}
3.4 caap-policy-diagnostic
Peios / Using Peios / Events / Kernel Access Events
Fires during the central access policy step of AccessCheck, for two
unrelated conditions distinguished by the kind field: a CAAP SACL that
failed to evaluate, and a staged policy that would have decided
differently from the effective one.
Event type string: caap-policy-diagnostic.
| Key | Type | Meaning |
|---|---|---|
subject | map | Subject record (§2.1). |
object_context | bin or nil | Caller-supplied object identifier. |
kind | string | sacl-error or staging-mismatch. |
phase | string | Which phase of CAAP evaluation produced the diagnostic. |
policy_sid | bin or nil | The policy involved. nil for staging-mismatch. |
rule_index | uint or nil | The rule involved. nil for staging-mismatch. |
reason | string or nil | Diagnostic text, for sacl-error. |
requested_access | uint | The mask the caller requested. |
effective_granted_access | uint | Total granted under the effective policy. |
staged_granted_access | uint | Total the staged policy would have granted. |
object_results_differ | bool | Whether staged and effective differed for this object. |
process | map | Process record (§2.2). |
Every key is always present; several are nil depending on kind.
3.4.1 kind = sacl-error #
A central access rule's SACL could not be evaluated. policy_sid,
rule_index and reason identify what failed and where.
This is a defect in the policy, not in the access. The access is still decided; the event says a rule that should have participated could not.
3.4.2 kind = staging-mismatch #
A staged policy — one being trialled before it takes effect — would have produced a different result from the policy actually in force. This is the mechanism's whole purpose: run the new policy in parallel and report where it would have changed something, before it can break anything.
policy_sid and rule_index are nil here. The mismatch is a property
of the whole evaluation, not attributable to one rule.
3.4.3 The limits of the mismatch report #
Worth knowing before building anything on it.
It carries the two total granted masks and a boolean. It does not identify which rule differed, and it does not report which audit events would have changed.
For a mismatch that is purely in SACL behaviour, the two masks can be
equal while object_results_differ is true. A consumer comparing
only the masks will conclude nothing changed.
3.5 logon-session-destroyed
Peios / Using Peios / Events / Kernel Access Events
Fires when a logon session loses its last token reference and the kernel destroys it.
Event type string: logon-session-destroyed.
| Key | Type | Meaning |
|---|---|---|
session_id | uint | The destroyed session's LUID. |
user_sid | bin | The session's user SID. |
logon_type | uint | Interactive, Network, and so on. |
auth_package | string | The authenticating package — Kerberos, NTLM, local. |
created_at | uint | When the session was created. |
Every field is always present.
3.5.1 No subject, no process #
This is the only KACS event with neither. The session was the subject, and it has just ended. No process caused it — the last reference simply went away, which may have been any process exiting, or none in particular.
3.5.2 Correlating #
session_id is the same LUID that every token in that session reported
as subject.auth_id (§2.1), and that LCS events report as
caller.authentication_id (§2.3). It is the join key for everything
that session ever did.
With created_at, the event bounds a session's whole lifetime, which is
what makes it useful for reconstructing a login after the fact.
The event fires exactly once per session. Consumers — authd especially — use it to release session-scoped state: Kerberos tickets, cached directory data, per-session credentials.
3.5.3 There is no matching creation event #
Nothing is emitted when a session is created, so the pair is asymmetric. See §3.6 for what else is absent and why.
3.6 corrupt-sd
Peios / Using Peios / Events / Kernel Access Events
Fires when FACS encounters a structurally invalid security descriptor on a file.
Event type string: corrupt-sd.
| Key | Type | Meaning |
|---|---|---|
subject | map | Subject record (§2.1) for the access that triggered detection. |
object_context | bin or nil | Identifier of the object whose descriptor was corrupt. |
reason | string | What was wrong — sd_too_large, acl_malformed, sid_invalid. |
process | map | Process record (§2.2). |
Every field is always present.
The subject is whoever happened to touch the file, not whoever caused the corruption. Nothing records that.
3.6.1 Rate-limited by design #
One event per inode per cache population. A corrupt descriptor read a thousand times during one mount's life produces one event, not a thousand.
This is deliberate: a filesystem with many corrupt descriptors would otherwise drown the audit stream at exactly the moment the stream is most needed. The consequence is that event count says nothing about access count — one event does not mean one access.
3.6.2 Informational only #
The kernel denies the access regardless. A corrupt descriptor fails closed, and this event is not part of that decision — it is what tells an administrator the corruption exists at all.
Without it, a file with a broken descriptor is simply inaccessible, with nothing anywhere explaining why.
3.6.3 Events that do not exist #
Three absences in the KACS set are worth stating, because each is a reasonable thing to look for:
- No
logon-session-created. Sessions are announced only when they end (§3.5). Track creation through authd's own records or by polling/sys/kernel/security/kacs/sessions. - No
token-created. A token's existence is observable through process inspection, not through an event. - No periodic or heartbeat events. Audit here is entirely event-driven. A silent stream means nothing happened, not that anything is broken.
These are intentional. The kernel's audit surface covers access decisions and session endings; other lifecycle tracking belongs to the layers above it.
4.1 STRATAFS_COPY_UP
Peios / Using Peios / Events / Filesystem Events
Fires on every StrataFS copy-up, successful or not.
Event type string: STRATAFS_COPY_UP.
| Key | Meaning |
|---|---|
path | The relative path within the mount, /-prefixed. |
provider_index | The stratum the object was copied from. |
provider_stratum | That stratum's path. |
create_index | The stratum it was copied into. |
create_stratum | That stratum's path. |
result_errno | Zero on success, the failure otherwise. |
Six keys, and no seventh.
4.1.1 No caller in the payload #
Nothing here names a token, and that is not an omission.
Copy-up preserves the source object's descriptor, so the resulting file records nothing about who caused it to exist. The caller is recovered from the envelope instead: KMES stamps the effective, true and process token GUIDs onto the header at ring-write time, and because copy-up runs in the caller's own context, those are the caller's (§1.2).
A consumer reading only payloads will conclude these events are anonymous. They are not — the identity is one level out.
4.1.2 ENOTDIR arrives here, not in the refusal event #
A parent materialisation that fails with ENOTDIR is reported through
this event, carried in result_errno, rather than through
STRATAFS_MUTATION_REFUSED (§4.2).
That is worth knowing when searching for a refusal and finding nothing: the record exists, under a different type, with all the required fields present.
4.2 STRATAFS_MUTATION_REFUSED
Peios / Using Peios / Events / Filesystem Events
Fires when a mutation is refused because of how the mount is arranged, rather than because of an access check.
Event type string: STRATAFS_MUTATION_REFUSED.
| Key | Meaning |
|---|---|
path | The relative path within the mount. |
operation | The operation name. |
provider_index | The provider stratum's index. |
provider_stratum | That stratum's path. |
errno | The refusal. |
| deferred flag | Whether the refusal was deferred. |
4.2.1 What counts as an arrangement refusal #
One explicit list, matching the specification's enumeration exactly:
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, removexattr, creation, tmpfile, unlink, rmdir, link,
supersede and rename.
EACCES is deliberately absent. A refusal produced by an access
check is audited by the mechanism that performed it, and StrataFS does
not duplicate those records. Looking here for a permission denial finds
nothing; look for access-audit (§3.1) instead.
4.2.2 What these records are for #
They 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 configuration, and it is otherwise visible only as an error returned to a caller that may well discard it.
Rollbacks are audited under this same type 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.
4.2.3 Two irregularities #
A refusal raised before a provider is known — creation, tmpfile, the
heads of link and rename — passes a provider index of -1, so
provider_stratum is emitted as an empty string. The specification asks
for the provider stratum in every refusal record, so this is a gap
rather than a design.
A refused deferred deletion is audited on any non-zero result, not
only on the arrangement errors, so one refused by an access check does
produce a StrataFS record. This is a deliberate exception to the
EACCES exclusion above: the requirement to audit a deferred deletion
is unconditional, because by then nobody is left to receive the error.
4.2.4 What is not audited #
Resolution, revalidation and enumeration emit nothing. They occur on every path operation, 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 against provider objects are audited by KACS under its own rules.
5.1 Registry Events
Peios / Using Peios / Events / Registry Events
LCS emits seven audit events through KMES. Six of them carry the caller summary (§2.3) rather than the subject record.
| Event | Emitted when |
|---|---|
LCS_KEY_OPEN_AUDIT | A key open matched a SACL audit ACE. |
LCS_BACKUP_START | Before REG_IOC_BACKUP reads any subtree data. |
LCS_BACKUP_COMPLETE | After a backup completes, or fails after starting. |
LCS_RESTORE_START | Before REG_IOC_RESTORE modifies any source state. |
LCS_RESTORE_COMPLETE | After a restore completes, or fails after starting. |
LCS_SOURCE_VALIDATION_FAILURE | LCS rejected malformed source data. |
LCS_SELF_CONFIG_INVALID | LCS rejected an invalid self-configuration value. |
Every payload is a msgpack map with string keys. GUIDs are 16-byte binary values; SIDs are binary KACS encodings.
5.1.1 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 at all.
The start/complete pairing is deliberate too. LCS_BACKUP_START is
emitted before any data is read and LCS_RESTORE_START before any
state is modified, so an operation that dies partway still leaves
evidence that it began.
5.1.2 Separately: watch records #
LCS also produces watch records, read from a key file descriptor. These are not KMES events, not msgpack, and not audit — see §5.4.
5.2 LCS_KEY_OPEN_AUDIT
Peios / Using Peios / Events / Registry Events
Fires when a key open matched a SACL audit ACE.
| Key | Meaning |
|---|---|
caller | Caller summary (§2.3). |
| key GUID | The key that was opened. |
requested_access | The mask after registry generic mapping, with MAXIMUM_ALLOWED re-added if the caller asked for it. |
granted_access | The mask granted. Forced to zero on a denial. |
| decision | allowed or denied. |
sacl_match_flags | Bit 0 for a success-audit match, bit 1 for a failure-audit match. No other bits. |
granted_access being zero on a denial is enforced, not merely
intended: a denied event carrying a non-zero granted mask is rejected as
a malformed payload.
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, itself gated by
SeSecurityPrivilege.
5.2.1 One matching SACL that produces nothing #
A request of MAXIMUM_ALLOWED alone maps to a desired mask of zero.
AccessCheck's SACL walk tests each audit ACE's mask against the mapped
desired access, and no ACE matches zero.
So an open with a matching audit ACE emits no event, and LCS emits nothing. This is a real hole in coverage for anyone auditing key opens: the one request shape that asks for everything is the one that records nothing.
5.2.2 When emission fails #
The policy is specific to this event, and differs from the bulk ones.
If LCS cannot construct a valid payload — corrupt internal state,
allocation failure, anything on the LCS side — the open fails with
EIO and no key fd is published. The audit is a precondition of the
access.
If the payload is valid but KMES cannot retain it — unavailable,
ring drops, capacity pressure, no consumer — the access decision and the
fd publication are unaffected. Loss accounting is KMES's problem, and
shows up as a synthetic.gap (§8.1) rather than as a failed open.
5.3 Validation and Configuration Failures
Peios / Using Peios / Events / Registry Events
5.3.1 LCS_SOURCE_VALIDATION_FAILURE #
Fires when LCS rejects malformed source data.
Carries the source slot identifier, then — where each is known — the
hive name, the RSI request id, the operation code and the key GUID. The
last field, validation_class, names what was wrong.
There are twelve classes:
| Group | Classes |
|---|---|
| Name fields | malformed_layer_name, malformed_key_name, malformed_value_name |
| Structural | malformed_response_payload, malformed_key_metadata, malformed_value_payload, malformed_delete_layer_orphan_list |
| Descriptors | malformed_security_descriptor, malformed_layer_metadata_security_descriptor |
| Sequencing | future_sequence_number, duplicate_winning_sequence_tie |
| Protocol | unknown_rsi_status_code |
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.3.2 LCS_SELF_CONFIG_INVALID #
Fires when LCS rejects an invalid self-configuration value.
Carries the parent path and value name of the offending parameter, the
expected type and numeric range, what was actually received — one of
missing, wrong_type or dword_out_of_range, with the actual type or
value where applicable — and the value LCS retained instead.
5.3.2.1 Expect nineteen of these on a first boot #
Because missing counts as invalid, a first boot before seed restore
emits one event per parameter on each refresh: nineteen events
against an empty Registry\ key.
That is correct and expected, and it is a noticeable share of the boot audit stream. A consumer alerting on validation failures needs to know it, or every fresh machine looks like it is failing.
5.4 Watch Records
Peios / Using Peios / Events / Registry Events
Watch records are not KMES events. They are binary records read from
a key file descriptor with read(), and they exist to tell a watcher
that something under a key changed.
They are documented here because an operator asking what the registry reports would otherwise miss them, but nothing else in this book applies to them: no msgpack, no envelope, no identity stamps, no audit meaning.
5.4.1 Reading #
A single read() returns as many complete records as fit in the
caller's buffer. A record is never split across two calls.
If the buffer cannot hold even the first queued record, read() fails
with EINVAL — the buffer is too small to make progress, and the caller
retries with a larger one. On an armed fd with an empty queue, read()
blocks, or returns EAGAIN under O_NONBLOCK.
Only records copied out in full are dequeued.
5.4.2 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 record,
and it is the only safe way to do so.
5.4.3 Subtree watches carry a path #
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: a u16 length, 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 by a separator because registry names can contain any Unicode character and value names can contain backslashes. A concatenated path string would be ambiguous.
OVERFLOW records are emitted in the bare eight-byte form, with no path
even on a subtree watch — there is no single key to name when the queue
itself overflowed.
The header offsets are ABI, named in uapi/pkm/lcs.h and listed in the
Peios Kernel TRM §5.A.
6.1 Job Events
Peios / Using Peios / Events / Service Events
peinit emits a structured event at every job and operation lifecycle transition. All of them go into the KMES ring buffer, encoded as msgpack per the envelope in §1.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, restarted, or not yet existing. The only thing peinit sends eventd over a socket is service output, which is a different path with different guarantees.
6.1.1 The three job events #
| Event | Fires when | Carries |
|---|---|---|
job.created | The job object exists. | Job identifier, service name, type, image path, identity, operation identifier. |
job.started | exec succeeded. | Job identifier, PID, cgroup path. |
job.ended | The process exited or was killed. | Job identifier, final state, exit code or signal, duration, failure cause. |
The event type is the dotted string; the fields form the msgpack
payload. The payloads are supersets of the summaries above — job.ended
in particular carries the whole record.
6.1.2 Ordering #
When one runtime step produces several lifecycle events, peinit emits them in causal order before committing the retained state for that step. A consumer sees the events in an order consistent with what happened, not in whatever order the writes completed.
6.2 Operation Events
Peios / Using Peios / Events / Service Events
Every operation event carries the same five fields: operation_id,
type, service, source, caller — null for a
lifecycle-generated operation — and state after the transition.
Seven types, each adding its own fields:
| Event | Adds |
|---|---|
operation.requested | — |
operation.started | — |
operation.completed | duration_ns, result |
operation.failed | duration_ns, failure_reason |
operation.cancelled | reason |
operation.merged | merged_into |
operation.aborted | duration_ns, reason |
6.2.1 duration_ns measures from creation #
Not from the start of execution. The reasoning is the same as for the operation timeout: what a caller waited is what matters, and queue time is part of it.
An operation that sat in a queue for a minute and then ran for a second reports 61 seconds, not 1.
6.2.2 One field, three names #
The same value appears under three spellings across two surfaces:
failure_reasononoperation.failedreasononoperation.cancelledandoperation.abortederrorin the control interface's operation view (PSPU §4)
A consumer correlating an event stream against the control interface has to map all three onto each other.
7.1 The Event Set
Peios / Using Peios / Events / Package Events
peipkg emits eleven event types into the kernel event subsystem. The caller's identity is stamped into the envelope by the kernel (§1.2) and is not part of the payload.
| Type | Emitted for | Emitted today |
|---|---|---|
peipkg.install | A successful install | yes |
peipkg.upgrade | A successful upgrade, downgrade, or undo | yes |
peipkg.uninstall | A successful uninstall | yes |
peipkg.refresh | A repository refresh, successful or partially failed | yes |
peipkg.transaction-failed | A rejected or rolled-back transaction | yes |
peipkg.recovery | A recovery resolved through peipkg recover | yes |
peipkg.authorisation | An operator authorisation record | yes |
peipkg.repo-add | A repository add | yes |
peipkg.repo-remove | A repository remove | yes |
peipkg.claim | A claim grant or revoke | yes |
peipkg.config-change | A trust-policy or transport-flag change | no |
peipkg.config-change is specified and not emitted. A trust-policy
or transport-flag change today produces no event at all.
7.1.1 Payload fields #
| Field | Content |
|---|---|
txn_id | The transaction identifier. |
outcome | success, rejection, or rollback. |
repo | The repository, for repository operations. |
detail | The rejection reason, the operation count, or the authorised action. |
timestamp | RFC 3339, UTC. |
packages | Name, version and architecture per package. |
Note timestamp here is an RFC 3339 string, not the uint used
elsewhere in this book (§1.3). peipkg carries its own.
7.1.2 Emission depends on a privilege #
An audit privilege on the caller's token. Without it, emission fails, peipkg warns, and the operation proceeds unaudited. The absence of an event does not mean the operation did not happen.
On a kernel with no emit call, emission is a silent successful no-op.
7.2 What Is Not Recorded
Peios / Using Peios / Events / Package Events
The gaps in peipkg's audit trail are specific, and each one is a place where something happened and nothing was written.
- An install or upgrade event carries no source repository, although one is known at the time.
- A committed cross-root operation's success event carries no transaction identifier, so it cannot be correlated with the rest of its transaction.
- Automatic recovery at the head of an ordinary operation emits
nothing. Only recovery through
peipkg recoverproducespeipkg.recovery. peipkg recover's failure paths emit nothing. A recovery that fails leaves no record that it was attempted.- Declining at a prompt emits nothing.
- Enabling insecure transport emits no authorisation record, and so
does installing unsigned content under an
optionalpolicy — the two decisions most worth recording are the two that are not. peipkg-composeemits nothing at all. Image composition is entirely unaudited.
7.2.1 Reading the trail with these in mind #
Two consequences follow for anyone building on this stream.
Absence is not evidence. An operation with no event may have been performed by a caller without the audit privilege, may have taken one of the paths above, or may not have happened. The three are indistinguishable from the stream.
Transaction correlation is incomplete. txn_id ties an operation's
events together, but the cross-root success case omits it, so a
reconstruction keyed on txn_id will silently drop those.
8.1 Synthetic Events
Peios / Using Peios / Events / Event Daemon Events
Synthetic events are records eventd generates about itself. They are written straight into 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 wall-clock timestamp taken when eventd
generated the record, and a type string prefixed synthetic., which is
what distinguishes them in the events table — there is no separate
record-type column.
| Condition | Type |
|---|---|
| Lost events detected on a CPU | synthetic.gap |
| eventd started and attached to KMES | synthetic.startup |
| Graceful shutdown beginning | synthetic.shutdown |
| A write to any store failed | synthetic.storage_error |
| A configuration value changed at runtime | synthetic.config_change |
Five, and no more. Payload schemas are in the eventd manual §3.2.
8.1.1 There is no event for bad input #
Deliberately. Log and metric datagrams arrive unauthenticated from arbitrary local processes, and emitting a durable record per bad datagram would hand every process an amplification primitive (PSPU §3.4).
These five are conditions eventd observed about itself, not reactions to what it was sent.
8.1.2 Which shard they land in #
CPU-specific — synthetic.gap — goes to the shard assigned to the
CPU that generated it, handed to that writer thread alongside that CPU's
ordinary events. A gap record travels with the events it describes.
Daemon-wide — startup, shutdown, config changes, storage errors — go to shard 0 when shard 0 is writable, otherwise to the lowest-numbered writable active shard. If no shard is writable, the event is skipped and the failure is logged to standard error.
A storage error is why the fallback exists. It describes a failure on one 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. Writing the record of a shard's failure into that shard would lose it exactly when it matters.
8.1.3 The timestamp is when eventd noticed #
Not when the condition occurred.
A synthetic.gap is stamped at detection, which may be long after
the events it describes were overwritten — and after a restart it may be
the first thing written in a new boot about events lost in the previous
one.
An investigation that treats a gap record's timestamp as the time of the loss will look in the wrong window.
8.1.4 Storage and ordering #
Synthetic events live in the same shard databases as KMES events and
take part in the same batching, retention and queries. Access control
treats their types like any other, so
Machine\System\eventd\Security\Events\synthetic governs them.
They are ordered by their eventd-assigned timestamp and take no part in per-CPU sequence numbering.
Appendix A All Event Types
Peios / Using Peios / Events
Every event type in this book, in one table. Thirty-four types across six emitters, plus the registry's watch records, which are a separate mechanism.
A.1 KMES events #
| Type | Emitter | Where |
|---|---|---|
access-audit | KACS | §3.1 |
continuous-audit | KACS | §3.2 |
privilege-use | KACS | §3.3 |
caap-policy-diagnostic | KACS | §3.4 |
logon-session-destroyed | KACS | §3.5 |
corrupt-sd | KACS | §3.6 |
STRATAFS_COPY_UP | StrataFS | §4.1 |
STRATAFS_MUTATION_REFUSED | StrataFS | §4.2 |
LCS_KEY_OPEN_AUDIT | LCS | §5.2 |
LCS_BACKUP_START | LCS | §5.1 |
LCS_BACKUP_COMPLETE | LCS | §5.1 |
LCS_RESTORE_START | LCS | §5.1 |
LCS_RESTORE_COMPLETE | LCS | §5.1 |
LCS_SOURCE_VALIDATION_FAILURE | LCS | §5.3 |
LCS_SELF_CONFIG_INVALID | LCS | §5.3 |
job.created | peinit | §6.1 |
job.started | peinit | §6.1 |
job.ended | peinit | §6.1 |
operation.requested | peinit | §6.2 |
operation.started | peinit | §6.2 |
operation.completed | peinit | §6.2 |
operation.failed | peinit | §6.2 |
operation.cancelled | peinit | §6.2 |
operation.merged | peinit | §6.2 |
operation.aborted | peinit | §6.2 |
peipkg.install | peipkg | §7.1 |
peipkg.upgrade | peipkg | §7.1 |
peipkg.uninstall | peipkg | §7.1 |
peipkg.refresh | peipkg | §7.1 |
peipkg.transaction-failed | peipkg | §7.1 |
peipkg.recovery | peipkg | §7.1 |
peipkg.authorisation | peipkg | §7.1 |
peipkg.repo-add | peipkg | §7.1 |
peipkg.repo-remove | peipkg | §7.1 |
peipkg.claim | peipkg | §7.1 |
peipkg.config-change | peipkg | §7.1 — specified, not emitted |
A.2 Not KMES #
| Type | Emitter | Transport | Where |
|---|---|---|---|
synthetic.gap | eventd | Written direct to a shard | §8.1 |
synthetic.startup | eventd | Written direct to a shard | §8.1 |
synthetic.shutdown | eventd | Written direct to a shard | §8.1 |
synthetic.storage_error | eventd | Written direct to a shard | §8.1 |
synthetic.config_change | eventd | Written direct to a shard | §8.1 |
| Watch records | LCS | read() on a key fd | §5.4 |
A.3 Which carry an identity, and how #
| Events | Identity from |
|---|---|
KACS, all but logon-session-destroyed | subject record in the payload (§2.1) |
logon-session-destroyed | user_sid and session_id directly; the session was the subject |
| LCS, six of seven | caller summary in the payload (§2.3) |
| StrataFS, peinit, peipkg | The envelope only. No identity in the payload (§1.2) |
| eventd synthetic | None. eventd is describing itself |
A.4 Known holes #
Collected from the chapters, because a reader planning coverage needs them in one place:
peipkg.config-changeis never emitted (§7.1).- peipkg omits the source repository on install and upgrade, the
transaction id on committed cross-root success, and emits nothing for
automatic recovery, recovery failures, declined prompts, insecure
transport, unsigned installs under an
optionalpolicy, orpeipkg-compose(§7.2). - A registry key open requesting
MAXIMUM_ALLOWEDalone emits noLCS_KEY_OPEN_AUDIT, because the mapped desired mask is zero and no ACE matches zero (§5.2). - StrataFS refusals raised before a provider is known emit an empty
provider_stratum(§4.2). caap-policy-diagnosticwithkind = staging-mismatchdoes not identify which rule differed, and its two masks can be equal whileobject_results_differis true (§3.4).- There is no
logon-session-created, notoken-created, and no heartbeat (§3.6). - peipkg emits nothing when the caller's token lacks the audit privilege (§7.1).
Constants and catalogs
Peios / Using Peios / 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 / Using Peios / 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 / Using Peios / 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 in a privilege-use event:
SeSecurityPrivilegeSeTakeOwnershipPrivilegeSeBackupPrivilegeSeRestorePrivilegeSeRelabelPrivilege
Any other bit fails the audit encoder closed rather than emitting an unnamed privilege. See the Events Index §3.3.
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 / Using Peios / 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 / Using Peios / 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_SESSIONID | 0x0100 | Change interactive_session_id. Additionally requires SeTcbPrivilege. |
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 / Using Peios / 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. |
Values 1, 6, 7 and 10 upward are reserved.
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 an audit event on every successful access. |
OBJECT_ACCESS_FAILURE | 0x02 | Force an audit event on every failed access. |
PRIVILEGE_USE_SUCCESS | 0x04 | Emit a privilege-use event when a privilege's bits survive. |
PRIVILEGE_USE_FAILURE | 0x08 | Emit a privilege-use 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_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 / Using Peios / 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 8 |
Packages and repositories #
The package container, manifest, signature and repository index formats are PSPU §5.
Security descriptors (wire format)
Peios / Using Peios / 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 / Using Peios / 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 / Using Peios / Wire Formats Reference
The binary specs that kacs_create_token and kacs_create_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 / Using Peios / Kernel ABI Reference
The kernel ABI is documented in the Peios Kernel TRM, in two appendices generated from uapi/pkm/:
| Appendix | Covers |
|---|---|
| §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 |
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.
Related #
- Byte-level layouts by structure: Wire formats reference.
- Numeric constants by kind: Constants and catalogs.
- The tracepoints the kernel exposes: Kernel tracepoints.
Linux compatibility
Peios / Advanced Peios / 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 use kacs_open_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 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 / Advanced Peios / 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.
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 kacs_open_peer_token — 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 / Advanced Peios / 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 (SeBindPrivilegedPortPrivilege, SeTcbPrivilege, 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. |
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_NET_BIND_SERVICE | SeBindPrivilegedPortPrivilege (Peios-custom) |
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 / Advanced Peios / 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 / Advanced Peios / 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 kacs_open_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: kacs_open_peer_token #
kacs_open_peer_token is the KACS-native equivalent. The call:
token_fd = kacs_open_peer_token(socket_fd)
Returns a token fd reflecting the peer's complete identity at connect time. The fd carries TOKEN_QUERY | TOKEN_IMPERSONATE access — enough to inspect the token's full state and to install it as an impersonation token.
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_IMPERSONATEorkacs_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_open_peer_token then KACS-aware logic |
| Act on the peer's behalf (impersonate) | kacs_impersonate_peer or kacs_open_peer_token + KACS_IOC_IMPERSONATE |
| Capture peer identity at connect for later use | kacs_open_peer_token (store the fd) |
| Send a credential along a datagram message | SCM_CREDENTIALS for compat; for security, pass the token fd via SCM_RIGHTS |
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_open_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, kacs_open_peer_token would produce the correct result because the token carries the full identity. The migration path for an existing service is: replace SO_PEERCRED calls with kacs_open_peer_token, 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 / Advanced Peios / 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 / Advanced Peios / 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, services, networks and the rest are untouched. They are not identity, and coupling them to this would be a decision about DNS taken for reasons about principals.
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.
1.1 Scope
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.
5.1 Section Addressing
Peios / Advanced Peios / 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 / Advanced Peios / 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. */
5.3 Granularity
Peios / Advanced Peios / Conventions / Citation and Addressing
The finest addressable unit in this corpus is the article — the
§N.M form of §5.1.
There is no addressing below it. Headings within an article are not numbered, and there is no scheme for citing an individual normative statement.
5.3.1 Why not #
An earlier corpus defined one: 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.
Article-level citation has proved sufficient in practice. An article is small enough to be a useful target and stable enough to be worth citing.
5.3.2 Writing for citability #
Because the article is the unit, 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.
Where a single statement genuinely needs to be picked out, a citation MAY name it in prose alongside the article reference:
5.3.3 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 when the surrounding text is next touched.
5.4 Validation
Peios / Advanced Peios / 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 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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-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-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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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. |
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 / Advanced Peios / 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 / Advanced Peios / 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. The resulting ACE on the child contains the resolved SID, not the placeholder.
5.6.4 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.4.1 Owner #
If the creator SD specifies an owner, use it. Otherwise, use the token's owner SID.
5.6.4.2 Group #
If the creator SD specifies a group, use it. Otherwise, use the token's primary group SID.
5.6.4.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. 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 all ACEs (both explicit and inherited) are mapped to object-specific rights via the object type's GenericMapping. This ensures no unresolved generic bits persist on stored ACEs. 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.4.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.5 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 outside the scope of this document. Children with SE_DACL_PROTECTED or SE_SACL_PROTECTED set MUST be skipped during any re-propagation.
5.7 Ownership
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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)
- the obligations binding on each role (§2.19)
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.
- Password change, credential enrolment, and account administration, which are not 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.
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 / Advanced Peios / 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 two client roles — originating a logon and looking an identity up — and they are independent (§2.19).
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 logon connection's exchange, from the client's opening message 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. 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.
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 / Advanced Peios / 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
LogonStart. 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,
AccessGrantedorAccessDenied. - Both parties close the connection.
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 / Advanced Peios / 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.
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 / Advanced Peios / 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. See §2.4 for why this, and not process integrity, is the control.
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.
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 / Advanced Peios / 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 two exceptions, and both are stated where they apply:
LogonStart.supported_credential_types, which is a statement of
capability rather than an instruction (§2.7), and the field mask of
Lookup, whose reply says which bits it answered (§2.16).
2.7 LogonStart
Peios / Advanced Peios / 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 |
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.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 The two exceptions this field carries #
supported_credential_types is an optional trailing field, and it is
the only enumeration in this chapter whose unrecognised values are
dropped rather than refused. Both departures from §2.6 are deliberate,
and both are what make adding a credential type workable at all.
An absent field means Password, not an empty list. A decoder that
reaches the end of the body where this field would have been MUST read
it as a client supporting exactly the credential types that existed
before the field did — which is Password, and only Password. A
client predating the field could render nothing else. An absent field
and an explicitly empty one are therefore different statements, and an
implementation MUST NOT collapse them: the empty list is how a client
asks for a logon that requires no interaction, and reading it as
Password would turn that request into a prompt.
This is the one place where an empty length-framed field is not equivalent to an absent one (§2.6).
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 / Advanced Peios / 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 |
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.9 AccessGranted
Peios / Advanced Peios / 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 / Advanced Peios / PGSS / Logon
msg_type = 0x8003. Authority to client. The other terminal message;
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.
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 / Advanced Peios / 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 |
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 #
Adding a value to this enumeration is a breaking change and requires a version bump (§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 credentials cross in the clear #
Credential material travels this socket as plaintext. That is deliberate, and the alternative is worse.
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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 the fact that nothing records their membership (§2.16), are properties of the system.
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 / Advanced Peios / 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 |
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.
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.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 / Advanced Peios / 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.
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 / Advanced Peios / 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 Conformance
Peios / Advanced Peios / PGSS / Logon
A conforming implementation MUST satisfy every requirement in this chapter. This section collects them by role.
2.19.1 Authority obligations #
An implementation claiming to be a PGSS Logon authority MUST satisfy all of the following.
2.19.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.19.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.19.1.3 Conversation #
- Require
LogonStartas 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.19.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.19.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.19.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.19.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.19.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.19.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.19.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.19.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 (§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.19.2 Client obligations #
There are two client roles, and they are independent. A program may be either, both, or neither: 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.
2.19.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.19.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.19.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.19.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.19.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.19.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.19.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.19.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.
Appendix 2.A Message Reference
Peios / Advanced Peios / 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 |
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 |
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 |
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 |
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 / Advanced Peios / PGSS / Logon
Adding a value to any enumeration here is a breaking change requiring a version bump — see §2.6. The single exception is the field mask, noted below.
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 |
The gaps are deliberate: the numbering follows KACS, and values it does not define are not available here.
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.B.4 Message severities #
Carried in Message.severity (§2.8) as a u8.
| Value | Name |
|---|---|
| 0 | Info |
| 1 | Error |
2.B.5 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 originate this logon 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. |
| 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. |
2.B.6 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.7 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.8 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 |
This is the sole exception to the rule above. 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.9 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.10 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 / Advanced Peios / 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).
1.1 Scope
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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, persistence, indexing, or querying — these are the concern of the event storage service
- the encoding of payload bytes beyond their being a single MessagePack value
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 / Advanced Peios / 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 |
Values 4–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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 and at library load, 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 / Advanced Peios / 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.
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 / Advanced Peios / 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.
4.1 Scope and Roles
Peios / Advanced Peios / 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 / Advanced Peios / 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.3 Message Framing
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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
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 / Advanced Peios / 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 / Advanced Peios / 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.21.
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)
- the obligations binding on each role (§2.21)
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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.8 Registration
Peios / Advanced Peios / 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). |
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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / PSPU / Principal Source Interface
Two messages, both carrying PGSS Logon bodies verbatim.
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 / Advanced Peios / PSPU / Principal Source Interface
Exactly one terminal message ends a source conversation.
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 |
Note what is absent: no session, no token, no privileges, no integrity level. A source has no way to express them (§2.4).
Every field after groups is optional in the way §2.7 requires: a
source that does not write one has said nothing about it, and the
authority substitutes the default named below rather than failing.
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.21),
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 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.
The per-entry framing is what allowed unix_id to be added here without
breaking a decoder that predates it, and it will allow the next field
the same way.
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.3 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.4 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.21), 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.5 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.6 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.7 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 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.
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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.21) rather than as something enforced.
2.21 Conformance
Peios / Advanced Peios / PSPU / Principal Source Interface
A conforming implementation MUST satisfy every requirement in this chapter. This section collects them by role.
2.21.1 Authority obligations #
An authority federating over PSI MUST satisfy all of the following.
2.21.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.21.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.21.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.21.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.21.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.21.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.21.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.21.2 Source obligations #
A principal source MUST satisfy all of the following.
2.21.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.21.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,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.21.2.3 Answering #
- Send exactly one terminal message —
AssertionorRefusal— per conversation (§2.13). - 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.21.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.21.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.21.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.21.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.
Appendix 2.A Message Reference
Peios / Advanced Peios / 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 |
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 |
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, 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 / Advanced Peios / 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 |
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 / Advanced Peios / 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.21) |
| 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.
Appendix 2.D Prior Art
Peios / Advanced Peios / 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 / Advanced Peios / 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 any process that submits log records or metric samples. The producer role is unrestricted by design: the point of a system log is that everything on the system can write to it. A producer declares what it is (§3.7, §3.11) and the collector does not verify the declaration (§3.4).
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 producers a system permits to reach the ingestion channels, and how that is configured.
- 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 on the system is a log producer, 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 / Advanced Peios / 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 / Advanced Peios / 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 set of processes that may write logs is every process on the system; the set that may read them is not. 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.
This is the whole of the access control on the two ingestion channels: there is no per-record write authorization anywhere in this chapter (§3.4), so the descriptor on the socket is the only thing standing between a process and the ability to write a log line under any name it likes.
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 / Advanced Peios / 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 unauthenticated and arrives from arbitrary local processes (§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 every process on the system an amplification primitive: a cheap malformed datagram producing an expensive durable record. Silence is the defence.
The rule binds only on responses to input. A collector MAY record its own internal conditions, and the mainline collector records several (eventd TRMP §2.6).
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 / Advanced Peios / 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 / Advanced Peios / 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. A collector MUST receive log datagrams into a buffer of at least that size, 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).
There is no mechanism by which a producer can learn the ceiling. A datagram channel has no reply, so a producer either knows the value out of band or assumes the mainline default. A collector that lowers the ceiling below the mainline value MUST expect producers to keep sending at the old one, and silently losing what they send. Raising it is safe; lowering it is a change to the contract with every producer on the system.
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 Reachability #
Every process that produces output is a log producer, including processes that have not been written with a collector in mind.
The mainline arrangement is that the service manager holds each service's standard output and standard error at fork and forwards what it reads (peinit TRM). It is not a privileged producer: it uses this socket, this record format and these rules like anything else, and its role is to bridge programs that write to a file descriptor into an interface that expects datagrams.
A producer that wants control over its own metadata MAY write to the socket directly instead, with no registration, negotiation or setup of any kind. Direct submission and forwarded submission are the same interface; nothing distinguishes them on the wire, and a collector MUST NOT treat them differently.
3.7 Log Records
Peios / Advanced Peios / 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.
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 the identifier grammar of §3.19:
[A-Za-z_][A-Za-z0-9_.-]*
A collector MUST discard a record whose origin does not.
The constraint exists because 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 path separator or a quoting
character 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.
Quoted forms remain valid syntax everywhere an origin may be written (§3.24). A conforming origin never needs them, but a pattern may, 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.
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 / Advanced Peios / 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 / Advanced Peios / 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.
The channel works exactly as the log channel does, for the reasons given there: a declared datagram ceiling, truncated datagrams discarded whole, a receive queue of at most four times the ceiling, no backpressure, no notification, and no way for a producer to discover the ceiling (§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. 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 / Advanced Peios / 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 five fixed field names a metric
result carries — timestamp, boot_id, name, type, value —
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.
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 notification. A producer that changes the type of a metric it already emits has stopped emitting it, and the only visible symptom is that the series stops advancing.
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 value a query returns is a finite binary64.
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 / Advanced Peios / 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 / Advanced Peios / 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
- the kernel reported it truncated (§3.9)
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
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)
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, log an error, or increment anything a 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.
A producer that changes a metric's type is misconfigured, and the
misconfiguration is permanent and invisible: every sample is discarded
for as long as the series exists. The only signal available to an
operator is that the series stopped advancing while the producer
reported no error, and the only diagnosis is to query the series and
read its type (§3.25).
3.13 Series Identity
Peios / Advanced Peios / 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 / Advanced Peios / 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. Neither is per-client, because a collector cannot attribute connections to a caller beyond the token it has, and one client MAY therefore occupy every slot. A collector MUST NOT allow that to affect ingestion: queries and ingestion are separate channels precisely so that exhausting one cannot exhaust the other (§3.3).
3.15 Query Framing
Peios / Advanced Peios / 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 message ceiling #
A collector declares a maximum payload size, the query message ceiling, which bounds messages in both directions.
Inbound. A collector MUST refuse a request whose length exceeds
the 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 MUST ensure every response payload it sends is within the ceiling, chunking result records across messages as §3.16 describes.
3.15.2 The ceiling must admit every record #
A collector MUST NOT operate with a query message ceiling smaller than the largest record it can store.
A single result record is never split across messages (§3.16), so a record larger than the ceiling cannot be returned at all — and it cannot be skipped either, because skipping it would silently misreport what the store holds. It fails the query, and it fails every query whose range covers it, for as long as retention keeps it. One oversized record renders a span of history unreadable.
The two are related by configuration and nothing enforces the relation automatically: the ingestion ceilings (§3.6, §3.9) bound the largest record a producer can deposit, and the query message ceiling bounds the largest that can be handed back. An administrator who raises one MUST raise the other.
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 / Advanced Peios / 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, each within the message ceiling (§3.15). A
collector MUST NOT split one record across two messages. A record too
large to fit in a message alone MUST fail the query with an error rather
than being truncated, partially sent, or skipped.
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.17 Value Encoding
Peios / Advanced Peios / 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.
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 / Advanced Peios / 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 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 timestamp 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 / Advanced Peios / 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 log origin, a metric name and a metric label key at ingestion (§3.7, §3.10), which is what makes every stored identifier writable here without quoting.
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 origin_class == 2
WHERE granted_access == 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 / Advanced Peios / 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 |
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 origin_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 Strings fold case #
Every string comparison — ==, !=, STARTS_WITH, ENDS_WITH,
CONTAINS, 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.3 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 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 ==, !=, 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.5 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.6 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 / Advanced Peios / 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.
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
- 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 results are ordered by count descending. Ties are broken by
the group key under the value ordering above, then by the
representative's encoded bytes.
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 / Advanced Peios / 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:
timestamp, cpu_id, sequence, origin_class, event_type,
effective_token_guid, true_token_guid, process_guid, boot_id
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 top-level key with 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. Suppression is applied before descendants are
flattened, so a payload key named timestamp removes its entire subtree
from the query surface, not just itself.
An emitter SHOULD avoid payload keys that collide with header names.
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
Every other name resolves to a label. Ingestion refuses labels colliding with these five (§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".
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.
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.
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 / Advanced Peios / 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.access_denied -- exactly that type
EVENTS kacs.* -- every type beginning "kacs."
EVENTS *.denied -- every type ending ".denied"
EVENTS kacs.*.denied -- kacs.access.denied, kacs.token.denied, …
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 #
origin_class accepts named aliases as well as its integer values:
| Alias | Value |
|---|---|
userspace | 0 |
kmes | 1 |
kacs | 2 |
lcs | 3 |
EVENTS WHERE origin_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 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 origin_class COUNT
EVENTS SINCE 1h ago GROUP origin_class, event_type COUNT
EVENTS SINCE 1h ago GROUP event_type AVG queue_depth
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 timestamp 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 / Advanced Peios / 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. A conforming origin is always an identifier (§3.7).
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 / Advanced Peios / 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 operates on scalars throughout. Counter and gauge samples
are already scalar; a histogram sample is not scalar until a percentile
function has been applied. 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 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.
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 value. A sample whose rank
falls above the final cumulative count — meaning the percentile lies
in the overflow region above the highest boundary — also yields no
value, because the distribution does not record where in that region it
lies.
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.
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.
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.
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 histogram sample that yields a
percentile; one per window for window aggregations; one for a scalar
aggregation.
A histogram result carries only the percentile in value. 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 / Advanced Peios / 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.access_denied EXISTS
METRIC cpu.usage[] SINCE 1h ago WHERE EVENT synthetic.storage_error 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 / Advanced Peios / 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 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 / Advanced Peios / 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.access_denied, a
rule for kacs.access_denied, then one for 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 The write path is not access-controlled #
Nothing on either ingestion channel is authorized per record (§3.4). Access control here is a read-path mechanism only, and the Security Descriptor on each ingestion socket is the whole of the write-path control (§3.3).
The consequence is that origin and metric name are self-asserted
(§3.7, §3.10). Any process that can reach an ingestion socket may write
under any origin or metric name it likes, including one belonging to
another program — which permits fabricating a plausible operational
record, or burying a real one under noise attributed elsewhere.
Read-path rules limit who can see data written under a given
identifier; they do nothing about who wrote it. A collector MUST NOT
present a stored origin or metric name as evidence of provenance,
and a client MUST NOT treat one as authenticated.
3.29 Extension
Peios / Advanced Peios / 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 (§3.6, §3.9), the query message ceiling (§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.
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 one place this bites is the log and metric datagram ceilings, which a producer cannot discover and which silently discard what exceeds them (§3.6). Lowering either is a change to the contract with every producer on the system, and there is no mechanism by which any of them will find out.
3.30 Conformance
Peios / Advanced Peios / 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).
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 datagram ceiling, batched or not — and know that you cannot discover it (§3.6).
Choose a stable, conforming identifier. An origin or metric name matching the identifier grammar, naming you distinguishably, and using dots for hierarchy — because it is what access rules are written against and what queries select on (§3.7, §3.10).
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).
Assume nothing about provenance. An origin and a metric name are
what the producer claimed (§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 / Advanced Peios / 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 | 4096 – 1048576 | MaxLogDatagramBytes | §3.6 |
| Metric datagram ceiling | 262144 B | 4096 – 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 message ceiling | 65536 B | 1024 – 16777216 | MaxQueryMessageBytes | §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 |
| 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 |
| Transforms per query | at most 1 | §3.25 |
| Terminal aggregations per query | at most 1 | §3.25 |
| Queries per connection | exactly 1 | §3.14 |
3.A.5 The relation between two of them #
The query message ceiling MUST NOT be smaller than the largest record a collector can store, because a record that will not fit in a response fails every query that reaches it (§3.15). The ingestion ceilings bound what a producer can deposit; the query message ceiling bounds what can be handed back. Nothing enforces the relation automatically, and the mainline defaults do not satisfy it.
Appendix 3.B Query Language Reference
Peios / Advanced Peios / 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 |
| Result record larger than the message ceiling | execution | §3.16 |
| Aggregation producing a non-finite value | execution | §3.23, §3.25 |
"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 IN
NOT_IN IS NULL IS NOT NULL, combined with AND and OR (§3.20).
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 | timestamp cpu_id sequence origin_class event_type effective_token_guid true_token_guid process_guid boot_id | 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 | a label, or null |
3.B.8 Aliases #
origin_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, LOGS | timestamp descending |
| METRIC | timestamp ascending |
COUNT BY, TOP N BY | count descending |
DISTINCT | by the distinct value |
All ties are broken to a total order (§3.21).
Appendix 3.C Prior Art
Peios / Advanced Peios / 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, self-asserted identity, silent drop under pressure, a forwarder bridging programs that only know standard output.
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 registration, sender-named series. 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 / Advanced Peios / 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.
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.
4.2 Terminology
Peios / Advanced Peios / 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.
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 / Advanced Peios / 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 / Advanced Peios / 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.
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 / Advanced Peios / 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 / Advanced Peios / 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 from
the kernel. On Peios this is kacs_open_peer_token, which returns a
token descriptor for the peer.
The token obtained is the peer thread's effective token at the moment of the call. 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 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 / Advanced Peios / 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 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. |
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 | 0 |
GENERIC_WRITE | SYSTEM_RELOAD_CONFIG |
GENERIC_EXECUTE | SYSTEM_SHUTDOWN |
GENERIC_ALL | SYSTEM_SHUTDOWN | SYSTEM_RELOAD_CONFIG |
GENERIC_READ maps to nothing on the manager's descriptor because it
governs two actions and no queries.
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 |
shutdown | SYSTEM_SHUTDOWN |
reload-config | SYSTEM_RELOAD_CONFIG |
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.
- On denial, answer
ACCESS_DENIED, and record the attempt with at least the caller's SID, the target, and the right requested. The manager MUST NOT deny silently. - On grant, proceed.
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 manager MUST NOT reveal, through the response, that services were omitted. Reporting the omissions would answer the question the filtering exists to leave unanswered.
4.7.6 Not revealing what a caller may not see #
Where a command names an object 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.
4.8 Requests
Peios / Advanced Peios / 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. |
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.
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 / Advanced Peios / 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.
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 has its own shape (§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 two exceptions are mode, which appears only on a reload response,
and job_id in the notification event payloads, which is omitted when
there is no job.
4.10 Errors
Peios / Advanced Peios / 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 |
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
from a caller that can name them: the list filtering (§4.7) hides them
from a caller that cannot.
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 / Advanced Peios / 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 operation has a maximum lifetime, derived from the target service's own configured timeouts.
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 / Advanced Peios / 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 / Advanced Peios / 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 Commands
Peios / Advanced Peios / PSPU / Service Control and Notification
Three commands read state and change nothing.
4.14.1 status #
Returns everything the manager knows about one service.
| Field | Type | Meaning |
|---|---|---|
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). |
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. |
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 to null at the start of every
activation generation. A status string from a previous incarnation MUST
NOT survive a restart and be reported as though it described the current
process.
4.14.1.1 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.2 list #
Returns every service the caller may query, with a compact summary.
Exactly four fields per entry. 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 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.
4.15 System Commands
Peios / Advanced Peios / PSPU / Service Control and Notification
Two commands act on the manager rather than on a service.
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 During shutdown #
Once the manager is shutting down, it MUST reject every command except
status, list and operation-status with INVALID_STATE.
Those three are permitted because they change nothing and because a
client watching a shutdown proceed has a legitimate reason to keep
looking. 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / PSPU / Service Control and Notification
A datagram on this channel claims to be a service 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. The manager MUST match the attested PID against the main jobs it is supervising. A hook process, a health check, or a child a service forked MUST NOT be able to notify on the service's 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.
- The job's activation generation is the service's current one.
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.
4.19 Notification Fields
Peios / Advanced Peios / 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. |
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.
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. |
ERRNO | free text | An errno-style error number. |
EXIT_STATUS | free text | An exit status, informationally. |
All three MUST be authenticated like any other field and MUST be recorded by the manager as structured events. 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 MUST additionally be retained and exposed as status_text in
the status shape (§4.14). ERRNO and EXIT_STATUS MUST NOT be
retained.
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 / Advanced Peios / 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.
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 / Advanced Peios / 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.
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;
- introduce a service state, transition cause, operation state, operation type, operation source or job type outside §4.B;
- introduce a reload mode outside the three in §4.13;
- introduce a command, or 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 / Advanced Peios / 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, records every denial, 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.
Commands. Implements all ten, 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.
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. 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.
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, and no newline inside a
STATUS value. 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 / Advanced Peios / 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 | 32 | 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 |
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 / Advanced Peios / PSPU / Service Control and Notification
Every enumerated value that appears on the control channel. All are lower snake case. A manager MUST NOT emit a value outside these sets 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.
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 three states satisfy dependents: active, 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,
process_unkillable
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, ad_hoc
Only service_main appears in current_job.
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
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
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 Shutdown type #
poweroff, reboot, halt
Request-only; the manager does not echo it.
5.1 Scope and Roles
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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; 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.
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 / Advanced Peios / 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 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 / Advanced Peios / 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 / Advanced Peios / 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.
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 |
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 |
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 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.5 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.
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.6 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.7 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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. |
| 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 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.3 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 / Advanced Peios / 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/bin/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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 and the determinism rules of §5.11.
- 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.
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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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, 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 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.6 Signature algorithms #
| Algorithm | Identifier | Status |
|---|---|---|
| Ed25519 | ed25519 | REQUIRED; the only valid value |
Defined in §5.29.
5.B.7 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.8 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.9 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.10 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.11 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.12 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.13 Permitted claim path locations #
The destinations above, plus /run/ and the well-known root-level name
/init. Defined in §5.23.
1.1 Overview
Peios / Advanced Peios / 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.
2.1 Overview
Peios / Advanced Peios / 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), and behaviour under failure (§2.7).
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 / Advanced Peios / 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 / Advanced Peios / 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.
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 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 non-preemptible window is a few hundred nanoseconds.
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, no counter is incremented, and no gap is visible. Events emitted in this window are simply gone.
- If the ring's recorded CPU identity does not match the executing CPU, single emission treats it as a structural drop (sequence consumed, counter incremented), while both batch paths return early without consuming anything.
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 preemption-disabled section:
- 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 is re-enabled, and only then is the futex wake syscall work performed, outside the non-preemptible window.
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 / Advanced Peios / 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 / Advanced Peios / 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 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.4 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.
During batch writes the running write and tail positions are kept in
locals; nothing is published until the batch ends, when the tail and
then the write position get one release store each, followed by a
single need_wake check — provided at least one event was written.
Consumers therefore observe a batch atomically, and see one tail
transition per batch rather than one per overwritten event.
2.5.5 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.6 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 but emits no event. 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 / Advanced Peios / 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.
Applying a plan is all or nothing, and the capacity swap runs first.
A BufferCapacity change that cannot be applied therefore also
prevents MaxEventSize, MaxNestingDepth, and
MaxEmitRatePerProcess from being applied in the same pass, even
though those three are valid and would otherwise take effect
immediately for subsequent syscalls. 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_SELF_CONFIG_INVALID reports one missing or invalid value. Its
payload is a msgpack map of exactly nine keys, in order:
configuration_parent_path (always Machine\System\KMES),
configuration_name (the canonical name), expected_type,
expected_min and expected_max (from the key's definition),
received_kind (one of missing, wrong_type, u32_out_of_range,
u64_out_of_range — a malformed payload length reports
wrong_type), received_type (the actual registry type code for a
wrong-type value, nil otherwise), received_value (the numeric value
for an out-of-range value, nil otherwise), and retained_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_SELF_CONFIG_INVALID events
and retains all four defaults.
KMES_BUFFER_SWAP_FAILED reports a valid BufferCapacity change that
could not be applied because replacement rings could not be
allocated. Its payload is a three-key map: requested_capacity,
retained_capacity, and errno — the last carrying the positive
value of ENOMEM as an unsigned integer. It is emitted only for
allocation failure; a swap abandoned because migration hit a corrupt
size field produces no event.
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 / Advanced Peios / 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, the existing rings stay live
at their current size, the configuration change is not applied, no
generation changes, and consumers are unaffected. A
KMES_BUFFER_SWAP_FAILED event records the requested and retained
capacities (§2.6). KMES does not retry; the next configuration write
or a reboot triggers another attempt.
2.7.5 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.6 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.7 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.8 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.9 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.
Appendix 2.A KMES ABI Reference
Peios / Advanced Peios / 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/.
| Number | Constant | Signature |
|---|---|---|
| 1090 | 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 | kmes_attach(unsigned int cpu_id, u64 __user *capacity) |
| 1092 | 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 |
|---|---|
KMES_ORIGIN_USERSPACE | 0 |
KMES_ORIGIN_KMES | 1 |
KMES_ORIGIN_KACS | 2 |
KMES_ORIGIN_LCS | 3 |
Ring-slot discovery.
Ring slots are indexed by logical CPU id and the array is sized by the kernel's nr_cpu_ids, so a slot inside the array holds no ring when that CPU is not possible. Counting up from 0 until SYS_KMES_ATTACH returns -EINVAL therefore stops at the first hole and misses every ring above it, leaving those CPUs' events permanently unreachable.
Call SYS_KMES_ATTACH with cpu_id KMES_ATTACH_QUERY_SLOTS to learn the slot count instead. It writes the count through the capacity argument, returns 0, and opens no descriptor. Enumerate 0 .. count-1 and treat -EINVAL as "this slot holds no ring", not as the end of the array.
The sentinel is outside the index space for good: nr_cpu_ids is bounded by CONFIG_NR_CPUS, which cannot reach 2^32-1.
| 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 |
Privilege requirements.
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) |
On-wire event header.
Every event in a ring begins with a fixed 77-byte header, followed by event_type_len bytes of type string and then the msgpack payload. Events abut at event_size stride, so a header is not generally aligned; it crosses the ABI as raw bytes, not a C struct. Its fields, in order:
__u32 event_size total event byte length (header + type + payload)
__u32 header_size byte offset from the event start to the payload
__u64 timestamp_ns
__u64 sequence
__u16 cpu_id
__u8 origin_class one of KMES_ORIGIN_* above
__u8 effective_token_guid[16]
__u8 true_token_guid[16]
__u8 process_guid[16]
__u16 event_type_len length of the type string following the header
The three GUIDs are 16-byte Microsoft GUID binary values (Data1/Data2/Data3 little-endian, Data4 raw), captured from KACS at emission time; the null GUID (16 zero bytes) means identity was unavailable (KACS not initialised, or no process context). KMES copies them opaquely.
Read each field at its KMES_EVENT_*_OFFSET below. header_size locates the payload: it is KMES_EVENT_HEADER_BASE_SIZE + event_type_len. A future revision may grow the header, so consumers must use header_size, not the end of the type string, to find the payload.
| 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 |
Ring-buffer metadata layout.
An attached ring is mmap'd as:
page 0 producer metadata (read-only to the consumer)
page 1 consumer metadata (read-write)
ring data the event bytes, mapped twice back-to-back so an event
that wraps the buffer end is still contiguous.
The producer metadata page begins with KMES_RING_MAGIC.
| 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 / Advanced Peios / 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, so they are reachable
from the x32 ABI as well as from x86-64.
CONFIG_SECURITY_PKM_KUNIT compiles in the in-kernel test harness.
3.1 Overview
Peios / Advanced Peios / 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).
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.
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 / Advanced Peios / 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 / Advanced Peios / 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) #
| Field | Type | Description |
|---|---|---|
user_sid | SID | The token's primary identity. |
user_deny_only | bool | When true, the user SID matches only deny ACEs, never allow ACEs. Set at creation by CreateToken or FilterToken. True whenever write_restricted is true. |
groups | SID_AND_ATTRIBUTES[] | Group memberships. The set of SIDs is fixed at creation; the per-group attribute flags are adjustable. |
restricted_sids | SID_AND_ATTRIBUTES[]? | Secondary SID list for restricted tokens, null on unrestricted ones. Set at creation by CreateToken or FilterToken. AccessCheck treats the list as presence-based: a restricting SID participates whenever it is present, and neither SE_GROUP_ENABLED nor SE_GROUP_USE_FOR_DENY_ONLY affects restricted-pass matching. |
write_restricted | bool | When true, the restricted SID check applies only to write access. Set at creation by CreateToken or FilterToken. |
The logon SID — S-1-5-5-X-Y, tying the token to its
LogonSession — is not stored as a token field at all. It is derived
from the session on every read, and materialised once in groups
carrying SE_GROUP_LOGON_ID, which is where AccessCheck finds it.
The group SID set is fixed at creation — adjustment never adds or
removes a SID. Individual groups can be enabled or disabled by
modifying SE_GROUP_ENABLED, within two limits: a mandatory group
(SE_GROUP_MANDATORY) cannot be disabled, and a deny-only group
(SE_GROUP_USE_FOR_DENY_ONLY) cannot be re-enabled.
A token holds at most 1024 group entries including the kernel-injected logon SID, so CreateToken accepts at most 1023 caller-supplied groups.
3.2.2.2 Token type (fixed) #
| Field | Type | Description |
|---|---|---|
token_type | enum | Primary or Impersonation. |
impersonation_level | enum | Anonymous, Identification, Impersonation, or Delegation. Primary tokens always carry Anonymous. |
3.2.2.3 Integrity (fixed) #
| Field | Type | Description |
|---|---|---|
integrity_level | uint | Numeric integrity level. Standard values are 0 (Untrusted), 4096 (Low), 8192 (Medium), 12288 (High), 16384 (System), but any unsigned integer is valid and is compared numerically against object labels. |
mandatory_policy | flags | NO_WRITE_UP (0x0001) and NEW_PROCESS_MIN (0x0002). Per-token MIC enforcement policy, set at creation. |
mandatory_policy is immutable: a process cannot change its own MIC
constraints at runtime, neither loosening nor tightening them. This is
a deliberate departure from MS-DTYP, where the mandatory policy is
runtime-modifiable — which reduces MIC to a constraint that stops only
processes not actively trying to bypass it. Immutability is what makes
MIC a real boundary here, and it is also what allows the impersonation
integrity ceiling (§3.5) to be enforced unconditionally.
3.2.2.4 Privileges (adjustable) #
Each privilege on a token has four independent states.
Present — the privilege exists on the token. A present privilege can be removed permanently, but no privilege can be added after creation. Enabled — the privilege is currently active; only present privileges can be enabled or disabled. Enabled by default — the creation-time enabled state, which adjustment can restore. Used — the privilege has been exercised during this token's lifetime; monotonic, and never cleared once set.
The lifecycle runs: present and disabled, to enabled, to used, then optionally disabled, then optionally removed permanently. Removal clears the privilege from the present, enabled, and enabled-by-default states together.
The four states are encoded as four 64-bit bitmasks, one bit per defined privilege, which makes a privilege check constant-time and a multi-privilege operation atomic.
3.2.2.5 Elevation (one-way) #
| 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) #
| Field | Description |
|---|---|
owner_sid_index | Index into [user_sid, groups...] selecting the default owner SID for new objects: 0 is the user SID, 1..N are groups[0..N-1]. The referenced SID is the user SID or a group carrying SE_GROUP_OWNER. |
primary_group_index | Index into [user_sid, groups...] selecting the default primary group. The referenced SID is the user SID or any group SID on the token. |
default_dacl | The DACL applied to objects this token creates when no explicit descriptor is supplied. |
Storing indices rather than SID copies keeps the owner and primary group consistent with the group array they name.
3.2.2.7 Metadata (fixed) #
| Field | Type | Description |
|---|---|---|
token_id | LUID | Unique identifier for this token instance. |
token_guid | UUID | 128-bit identifier for this token instance, generated by the kernel at creation and immutable. Used by KMES and other kernel-internal consumers for identity stamping and event correlation. |
auth_id | LUID | The LogonSession LUID, linking the token to the authentication event that produced it. |
source | TOKEN_SOURCE | Who minted the token: an 8-character name plus a LUID. |
created_at | timestamp | When the original token was minted by CreateToken. Copied unchanged by DuplicateToken, FilterToken, and NEW_PROCESS_MIN, so it tracks original minting rather than duplication. |
expiration | timestamp | When the token becomes invalid; zero means no expiry. Not enforced by AccessCheck. |
origin | LUID | The originating LogonSession for derived tokens, such as S4U or network logon. |
3.2.2.8 Mutation tracking (adjustable) #
| Field | Description |
|---|---|
modified_id | Counter incremented on any token adjustment. |
Marking a privilege used is audit and accounting state rather than an
access-decision input, so it stays monotonic but does not bump
modified_id. Setting the elevation type is the one other mutation
that leaves the counter alone.
The counter is intended as a cache invalidation key — a modified_id
that has changed since a cached decision was taken means the decision
is stale. Nothing currently reads it for that purpose: it is
maintained on every adjustment and reported through the statistics
query class, and no cache anywhere invalidates on it.
3.2.2.9 Interactivity scope (adjustable) #
| 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) #
| Field | Type | Description |
|---|---|---|
confinement_sid | SID? | Places the token in a default-deny sandbox; null means unconfined. When set, AccessCheck switches to default-deny and access requires an explicit grant to this SID or to a SID present in confinement_capabilities. |
confinement_capabilities | SID_AND_ATTRIBUTES[] | Declared access capabilities for a confined process, empty if none. The attributes field is carried for wire-format uniformity only: AccessCheck treats capability membership as presence-based and consults neither SE_GROUP_ENABLED nor SE_GROUP_USE_FOR_DENY_ONLY when matching confinement SIDs. |
isolation_boundary | bool | Adds namespace filtering on top of confinement, making objects outside the boundary invisible rather than merely denied. Requires confinement_sid. Settable at creation but not enforced. |
confinement_exempt | bool | Escape hatch: confinement restrictions are not evaluated at all. |
ALL_APPLICATION_PACKAGES participates only when it is present in
confinement_capabilities. KACS never synthesises it, and equally
never rejects an otherwise valid confined token merely because it is
present — strict confinement is expressed by the caller omitting it.
Deciding which capabilities a package token receives is authd's job
and policy tooling's, not the kernel's.
3.2.2.14 Audit (fixed) #
| Field | Type | Description |
|---|---|---|
audit_policy | u32 | Per-token audit overrides as a bitmask, fixed at creation — no adjustment operation exists. |
| Flag | Value | Description |
|---|---|---|
OBJECT_ACCESS_SUCCESS | 0x0001 | Audit successful object access. |
OBJECT_ACCESS_FAILURE | 0x0002 | Audit failed object access. |
PRIVILEGE_USE_SUCCESS | 0x0004 | Audit successful privilege exercises: the privilege contributed requested bits that survive into the final granted result. |
PRIVILEGE_USE_FAILURE | 0x0008 | Audit failed privilege exercises: the privilege contributed requested bits during evaluation, but they do not survive into the final result. |
The policy is additive. It forces audit events that system-wide policy would not generate, and cannot suppress events that system-wide policy requires. It follows impersonation: when service A impersonates client B and B's token has a category enabled, operations during impersonation are audited under B's identity. The creation default is 0.
3.2.2.15 Credential projection (fixed) #
| Field | Description |
|---|---|
projected_uid | Linux UID for the user SID, precomputed by authd when the token is minted (PSPU §2); 65534 for the anonymous identity. |
projected_gid | Linux primary GID, precomputed the same way; 65534 for the anonymous identity. |
projected_supplementary_gids | Linux supplementary GIDs, one per group SID, precomputed the same way. |
authd computes these at token creation and they are stored on the token; KACS never resolves a SID-to-UID mapping at runtime. Projection reflects all groups regardless of enabled state, so adjusting groups does not trigger recalculation. §3.10 covers how the projection is used.
3.2.2.16 Token security (adjustable) #
| 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 / Advanced Peios / 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 a 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 is treated as Medium.
- 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 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. 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.
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.
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; 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.
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. kacs_impersonate_peer
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 / Advanced Peios / PKM / KACS / Tokens
Three operations create tokens, each for a different purpose: CreateToken mints one from nothing, DuplicateToken copies one, and FilterToken produces a strictly weaker copy.
3.2.4.1 CreateToken #
Mints a new token from scratch. The caller supplies the
security-meaningful content; the kernel generates the internal
bookkeeping and validates the structural invariants. The operation is
gated by SeCreateTokenPrivilege.
The caller supplies user_sid, groups with their attributes,
privileges as privs_present plus privs_enabled, owner_sid_index,
primary_group_index, default_dacl, integrity_level,
mandatory_policy, token_type, impersonation_level, auth_id
referencing an existing LogonSession, expiration (0 for none),
audit_policy, source as a name plus LUID, user_claims,
device_claims, lcs_scope_guids, lcs_private_layers,
device_groups, restricted_sids, restricted_device_groups,
confinement_sid, confinement_capabilities, confinement_exempt,
isolation_boundary, write_restricted, user_deny_only,
projected_uid, projected_gid, projected_supplementary_gids,
origin, and interactivity_scope. The wire format is in §3.A.
Including the well-known implicit groups is the caller's
responsibility — Everyone (S-1-1-0), Authenticated Users
(S-1-5-11), and whatever else the principal's authentication context
implies, such as S-1-5-4 Interactive, S-1-5-6 Service, or
S-1-5-15 This Organization. The kernel injects none of these. The
logon SID is the sole kernel-generated group.
The kernel generates token_id as a LUID, token_guid, modified_id
initialised to token_id, created_at as the current time,
elevation_type always Default, the token's own default security
descriptor (§3.2.7), and logon_sid, derived from the LogonSession ID
as S-1-5-5-{id >> 32}-{id & 0xFFFFFFFF}.
The logon SID is injected into the groups array carrying
SE_GROUP_MANDATORY | SE_GROUP_ENABLED_BY_DEFAULT | SE_GROUP_ENABLED | SE_GROUP_LOGON_ID, appended after the caller's groups. Callers do not
include it themselves. Because the injected entry is appended,
owner_sid_index and primary_group_index are interpreted relative
to the caller-supplied groups — 0 for the user SID, 1..N for the
caller's groups — and not against the array with the logon SID in it.
Validation covers, in turn: that the caller holds
SeCreateTokenPrivilege; that every SID is structurally well-formed;
that the owner SID is the user SID or a group carrying
SE_GROUP_OWNER, resolved to owner_sid_index; that the primary
group SID is the user SID or a group on the token, resolved to
primary_group_index; that auth_id references an existing
LogonSession; that a Primary token carries impersonation level
Anonymous; that user_deny_only is true whenever write_restricted
is; that confinement_sid is present whenever isolation_boundary
is; that the wire format's elevation_type field is 0, since the
kernel always sets Default itself; and that the caller's group count
plus the injected logon SID fits the 1024-entry limit.
The optional LCS registry credential extension, when present, has to use the version and layout of §3.A, and carries at most 256 scope GUIDs and at most 256 private layer names, with no nil scope GUID, no duplicate scope GUIDs, no empty or overlong layer names, and no duplicate layer names under LCS case-insensitive matching. Malformed LCS credentials fail the whole call closed.
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; duplicating to Primary
forces impersonation_level to Anonymous. The impersonation level
may be chosen freely when the source is a Primary token, but when the
source is itself an impersonation token the new level has to be equal
to or lower than the source's — an Identification-level token cannot
be duplicated up to Impersonation or Delegation.
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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 logon-session-destroyed
event through KMES. authd subscribes to those events and uses them to
clean up associated credentials such as cached Kerberos tickets.
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
logon-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. Reading it is access-checked against a
synthetic descriptor granting read to SYSTEM and the creator, 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, and the
CAP_SYS_BOOT mapping selects between SeShutdownPrivilege and
SeRemoteShutdownPrivilege by inspecting the session's logon type. 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:
- authd decides a session has to end — an admin request, a security incident, an account deletion, or a user logging off.
- authd enumerates processes whose tokens carry the target
auth_idorinteractivity_scopeby walking/proc/*/token, opening each node's query-only inspection handle, and readingTokenStatistics, which includesauth_id. No dedicated enumeration syscall exists or is needed. - authd requests termination — through peinit for supervised services, through signals for user processes.
- The processes terminate, dropping their token references.
- The last reference drops and the session object is cleaned up.
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. authd has to account for this when enumerating token holders — the walk finds processes running under the session, not every process holding one of its tokens.
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 / Advanced Peios / 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.
kacs_open_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.
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 #
| Right | Value | Grants |
|---|---|---|
TOKEN_ASSIGN_PRIMARY | 0x0001 | Install as a process's primary token. Also requires SeAssignPrimaryTokenPrivilege on the caller's token. |
TOKEN_DUPLICATE | 0x0002 | Duplicate the token, or create a restricted copy with FilterToken. |
TOKEN_IMPERSONATE | 0x0004 | Install as a thread's impersonation token. |
TOKEN_QUERY | 0x0008 | Read token information: SIDs, groups, privileges, integrity, claims, source, statistics, elevation type. |
TOKEN_ADJUST_PRIVILEGES | 0x0020 | Enable, disable, or permanently remove privileges. |
TOKEN_ADJUST_GROUPS | 0x0040 | Enable or disable groups. |
TOKEN_ADJUST_DEFAULT | 0x0080 | Change the default DACL, owner SID, and primary group SID. |
TOKEN_ADJUST_INTERACTIVITY_SCOPE | 0x0100 | Change the interactivity scope. Also requires SeTcbPrivilege. |
Bit 0x0010, TOKEN_QUERY_SOURCE, is subsumed by TOKEN_QUERY: a
holder of 0x0008 can query source information too. The bit is not
reused for anything else, for format compatibility with MS-DTYP.
TOKEN_ALL_ACCESS is 0x000F01FF — the union of the token-specific
rights with STANDARD_RIGHTS_REQUIRED
(DELETE | READ_CONTROL | WRITE_DAC | WRITE_OWNER, 0x000F0000). The
named rights alone OR to 0x01EF; the reserved TOKEN_QUERY_SOURCE bit
adds 0x0010 to reach 0x01FF.
3.2.8.3 Generic mapping #
| Generic 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.
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 / Advanced Peios / 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 / Advanced Peios / PKM / KACS / The Process Security Block
3.3.2.1 Process identity (fixed at fork) #
| Field | Type | Description |
|---|---|---|
process_guid | UUID | 128-bit identifier for this process instance, generated by the kernel at fork and immutable for the process's lifetime. It is not copied from the parent — every process receives a new one. Used by KMES and kernel-internal consumers for identity stamping and event correlation. |
The process GUID is distinct from the PID. PIDs are recycled; process GUIDs are unique within a boot, and globally unique in practice. It is the stable correlation key that lets KMES attribute events to a process across its whole lifetime.
3.3.2.2 Protection (set at exec, fixed) #
| 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) #
| Field | Description |
|---|---|
lsv | Library Signature Verification. Only signed shared libraries load. When the process has pip_type != None the library's trust level has to be at or above the process's PIP trust; when pip_type = None any valid signature suffices and trust is not compared. |
wxp | Write-XOR-Execute Protection. No page is simultaneously writable and executable; W+X mappings and transitions between writable and executable are rejected. |
tlp | Trusted Library Paths. Shared libraries load only from approved directory prefixes. Weaker than LSV, since it trusts the path rather than the binary. |
cfif | Forward-Edge Control Flow Integrity. Hardware indirect-branch tracking — Intel IBT, ARM BTI — is locked on and cannot be disabled by the process. Not settable: see below. |
cfib | Backward-Edge Control Flow Integrity. The hardware shadow stack, Intel CET, is locked on and cannot be disabled by the process. |
pie | Position-Independent Executable Requirement. Non-PIE binaries are rejected at exec, so that ASLR is actually effective. |
sml | Speculation Mitigation Lock. Speculation mitigations are locked on and cannot be disabled by the process. |
Mitigations are inherited from the parent at fork and can be set by syscall, typically by peinit between fork and exec. 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.
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 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 / Advanced Peios / 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 #
| Right | Value | Meaning |
|---|---|---|
PROCESS_TERMINATE | 0x0001 | Send signals whose default action is termination. |
PROCESS_SIGNAL | 0x0002 | Send informational signals whose default action is to ignore: SIGCHLD, SIGURG, SIGWINCH. |
PROCESS_VM_READ | 0x0010 | Read process memory — ptrace peek, /proc/<pid>/mem, process_vm_readv. |
PROCESS_VM_WRITE | 0x0020 | Write process memory — ptrace poke, /proc/<pid>/mem, process_vm_writev. Includes debugger attach. |
PROCESS_DUP_HANDLE | 0x0040 | Extract file descriptors from the process through pidfd_getfd. |
PROCESS_SET_INFORMATION | 0x0200 | Change priority, CPU affinity, I/O priority, resource limits, process group membership where Linux permits it, timer slack, memory-placement policy or pages, and mutable /proc/<pid> task state — sched, autogroup, timens_offsets, timerslack_ns, coredump_filter, oom_adj, oom_score_adj, make-it-fail, fail-nth, latency and clear_refs, plus write intent on the coupled uid_map, gid_map, projid_map and setgroups seq files. |
PROCESS_QUERY_INFORMATION | 0x0400 | Inspect the process's token; read the detailed /proc/<pid>/* files — cmdline, status, io, limits, sched, autogroup, timens_offsets, personality, syscall, latency, timers, timerslack_ns, mounts, mountinfo, mountstats, coredump_filter, oom_adj, oom_score_adj, loginuid, make-it-fail, fail-nth, seccomp_cache, ksm_merging_pages and ksm_stat — plus read intent on the coupled uid_map, gid_map, projid_map and setgroups seq files; query Linux compatibility capability state through capget(pid); and query detailed scheduler, CPU-affinity and I/O-priority state. |
PROCESS_SUSPEND_RESUME | 0x0800 | Send signals whose default action is to stop or continue. |
PROCESS_QUERY_LIMITED | 0x1000 | Read basic process information: PID, process group ID, session ID, image name, state, CPU and memory usage — stat, statm, comm, wchan, schedstat, cpuset, cgroup, cpu_resctrl_groups, oom_score, sessionid, patch_state, stack_depth and arch_status. This is what ps and top show, it covers /proc/<pid>/stat, and it is the right required for pidfd_open() and for kill(pid, 0) existence probes. |
READ_CONTROL | 0x20000 | Read the process's own descriptor. |
WRITE_DAC | 0x40000 | Modify the process's DACL. |
WRITE_OWNER | 0x80000 | Change the descriptor's owner. |
Three /proc entries are not where the right names suggest.
maps, fd and environ are not gated by
PROCESS_QUERY_INFORMATION: they keep their upstream
PTRACE_MODE_READ_FSCREDS gating, which maps to PROCESS_VM_READ
— reading a process's memory map is treated as reading its memory,
which is defensible but is not what the right's name implies. And
cgroup sits in the PROCESS_QUERY_LIMITED set rather than the
detailed one.
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 / Advanced Peios / 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 / Advanced Peios / PKM / KACS / Privileges
Some operations do not fit the subject-object model at all. Rebooting the machine, loading a kernel module, changing the system clock, creating a token — these affect the system itself rather than a specific protected resource, so there is nothing to attach a security descriptor to. They still need authorization.
Privileges fill that gap. A privilege is the right to perform a particular system operation, carried on the token beside the principal's identity. Where a descriptor says "this principal may read this file", a privilege says "this principal may shut down the system". The descriptor lives on the object; the privilege lives on the subject.
3.4.1.1 Lifecycle #
A privilege is assigned by policy when authd creates the token, resolving the principal's assignments from security policy once, at creation. There are no runtime grants: a privilege absent at creation can never be added later.
The privilege then sits on the token in whatever enabled state it was created with. The kernel accepts any enabled set that is a subset of the present set, and takes the creation-time enabled set as the enabled-by-default set. authd issues every privilege it grants already enabled, on the reasoning that a privilege the holder had to enable before it worked would be a grant in name only — so the present-but-disabled resting state exists in the model and is reachable through AdjustPrivileges, but is not where privileges normally start.
A privilege that has been disabled is re-activated by explicitly enabling it through AdjustPrivileges.
When the privilege is exercised, the kernel checks that it is both present and enabled — a single mask test against both words — permits the operation, and records it as used.
For a standalone gate, "exercised" means the gate accepted that bit,
and the used bit is meant to be recorded even when a later independent
check — a process descriptor, PIP, or a malformed-input test — denies
the operation afterwards. Where the shared privilege helper performs
the check, it marks the bit immediately, and the impersonation gate
does the same. Three gates mark later and therefore record nothing
when a subsequent check fails: token creation marks after the token
has been constructed and its descriptor allocated, so a malformed
specification leaves the bit unset; primary token installation marks
after the same-user and same-LogonSession gate; and the CAP_SYS_BOOT
mapping marks after the remote-shutdown origin gate.
Used-state for the AccessCheck-influencing privileges follows the provenance rules of §3.8 instead.
Recording the used bit is not merely bookkeeping. Every gate treats a
failure to record it as a failure of the operation itself and returns
EPERM or EACCES.
Afterwards the privilege may be disabled, returning to rest, or
removed permanently, which clears it from the present, enabled,
and enabled-by-default states while preserving the used bit for
audit.
3.4.1.2 Two enforcement categories #
Standalone operation gates are the majority. They authorize specific operations that AccessCheck does not mediate — rebooting, loading modules, debugging processes — and the kernel simply checks whether the calling thread's token holds the privilege present and enabled before allowing the operation.
AccessCheck-influencing privileges alter the outcome of
AccessCheck itself, causing it to grant rights the object's DACL would
not grant on its own. They are evaluated inside the pipeline alongside
DACL rules, integrity policy, and confinement. There are five:
SeSecurityPrivilege grants ACCESS_SYSTEM_SECURITY for SACL access
(and doubles as a standalone gate for the audit-related Linux
capabilities); SeTakeOwnershipPrivilege grants WRITE_OWNER as a
post-DACL fallback; SeBackupPrivilege grants all read access;
SeRestorePrivilege grants all write access plus WRITE_DAC,
WRITE_OWNER, DELETE and ACCESS_SYSTEM_SECURITY; and
SeRelabelPrivilege loosens MIC's constraint on WRITE_OWNER for
non-dominant callers. §3.8 gives the exact mechanics.
3.4.1.3 Intent gating #
SeBackupPrivilege and SeRestorePrivilege are intent-gated. Other
AccessCheck-influencing privileges are self-scoping —
SeSecurityPrivilege only matters when ACCESS_SYSTEM_SECURITY is
requested — but backup and restore grant such broad categories of
access that evaluating them unconditionally would apply them to every
AccessCheck on the system.
AccessCheck therefore takes a privilege_intent parameter. A caller
passes BACKUP_INTENT for a backup-context operation and
RESTORE_INTENT for a restore-context one, and the corresponding
privilege is evaluated only when its flag is present. Without the
flag, these privileges are invisible to the pipeline.
Intent gating also keeps backup and restore inside the pipeline rather than short-circuiting it, which matters because later stages — PIP in particular — have to be able to constrain privilege-granted access.
3.4.1.4 Assignment #
Privileges are assigned by security policy, not by identity. Membership of the Administrators group confers no privilege by itself. Groups and privileges are orthogonal: groups determine which objects you can reach through DACLs, privileges determine which system operations you can perform.
An administrator defines policy — that members of Backup Operators
receive SeBackupPrivilege and SeRestorePrivilege, say. At
authentication authd resolves the principal's group memberships,
evaluates the policy against them, and creates the token carrying the
result. The kernel neither verifies nor evaluates that policy; it
trusts authd as a TCB component. The token then carries those
privileges for its whole lifetime.
3.4.1.5 Auditing #
Every exercise sets the token's monotonic used state for that privilege, and every standalone gate emits an ftrace event.
KMES audit events are emitted only for the five AccessCheck-influencing
privileges, and only when the token's audit_policy opts in through
PRIVILEGE_USE_SUCCESS or PRIVILEGE_USE_FAILURE. 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 privilege-use events for any
privilege.
3.4.2 Privilege Catalogue
Peios / Advanced Peios / 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. 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 #
| Privilege | Bit | Mask | Enforcement |
|---|---|---|---|
SeTcbPrivilege | 7 | 0x80 | Kernel standalone |
SeLockMemoryPrivilege | 4 | 0x10 | Kernel standalone |
SeIncreaseQuotaPrivilege | 5 | 0x20 | Kernel standalone |
SeLoadDriverPrivilege | 10 | 0x400 | Kernel standalone |
SeSystemProfilePrivilege | 11 | 0x800 | Kernel standalone |
SeSystemtimePrivilege | 12 | 0x1000 | Kernel standalone |
SeProfileSingleProcessPrivilege | 13 | 0x2000 | Kernel standalone |
SeIncreaseBasePriorityPrivilege | 14 | 0x4000 | Kernel standalone |
SeManageVolumePrivilege | 28 | 0x10000000 | Kernel standalone |
SeShutdownPrivilege | 19 | 0x80000 | Kernel standalone |
SeDebugPrivilege | 20 | 0x100000 | Kernel standalone |
SeAuditPrivilege | 21 | 0x200000 | Kernel standalone |
SeRemoteShutdownPrivilege | 24 | 0x1000000 | Kernel standalone |
SeTcbPrivilege is the catch-all for system operations with no more
specific privilege, and only TCB services need it. It has by far the
widest reach of any privilege: fourteen Linux capability mappings,
several token operations, the mount policy paths, the central access
policy cache, LogonSession creation and destruction, removal of
mandatory resource attribute ACEs during descriptor merge, a KMES rate
limit exemption, the LCS source authentication path, and the upgrade
of a linked-token query from an Identification-level copy to the real
token at full access.
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, or NewCredentials logon.
SeLoadDriverPrivilege loads and unloads kernel modules through
CAP_SYS_MODULE. SeDebugPrivilege attaches to and inspects any
process regardless of its descriptor, and does not bypass PIP (§3.7).
SeSystemtimePrivilege changes the clock,
SeIncreaseBasePriorityPrivilege raises scheduling priority and sets
CPU affinity for other processes, SeIncreaseQuotaPrivilege overrides
resource limits, SeLockMemoryPrivilege locks pages in physical
memory, and SeAuditPrivilege writes events to the audit log — it is
what KMES requires for userspace event emission.
SeProfileSingleProcessPrivilege attaches perf_event_open() to a
specific other process. It respects PIP dominance, and own-task
profiling requires nothing. SeSystemProfilePrivilege covers
system-wide profiling — per-CPU events, all-task sampling, kernel-mode
events — and does not respect PIP at the per-sample level, since
system-wide samples include PIP-protected tasks. It is an
operator-class privilege.
Those two and SeLoadDriverPrivilege share one mapping: CAP_PERFMON
is satisfied by any of the three, and every one the caller holds is
marked used. The two profiling privileges are otherwise disjoint
tiers.
3.4.2.4 Network #
| Privilege | Bit | Mask | Enforcement |
|---|---|---|---|
SeBindPrivilegedPortPrivilege | 63 | 0x8000_0000_0000_0000 | Kernel standalone |
Binds TCP and UDP ports below 1024, mapped through
CAP_NET_BIND_SERVICE. A custom Peios privilege, retaining the Linux
convention as defence in depth.
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 for submitting supervised
jobs through JFS. No kernel definition exists for it. 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 / Advanced Peios / 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.
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.2 Impersonation Gates
Peios / Advanced Peios / 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 / Advanced Peios / PKM / KACS / Impersonation
3.5.3.1 The sequence #
The client connects. It optionally sets the maximum impersonation
level through a syscall — the default is Impersonation — and calls
connect(). The kernel's LSM hook fires on the Unix stream
connection.
Identity is captured. The hook examines the client thread's
effective credential together with the socket's maximum level. 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 impersonates by calling kacs_impersonate_peer with
the connection fd. The kernel retrieves the stored token, 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.
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 calls kacs_impersonate_peer
again 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 Supported transports #
Socket-based impersonation through kacs_impersonate_peer works on
two 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(), impersonate, revert.
Three transports do not support it. SOCK_DGRAM is connectionless, so
identity would arrive as per-message credentials — a different model
that is not part of this syscall surface; datagram sockets create no
KACS peer token. Sockets from socketpair() are pre-connected and
unnamed, and while possession of the fd authorizes use of the channel,
no peer-token snapshot is installed. Pipes and FIFOs have no peer
credential mechanism at all.
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 — socket-based capture, an
SCM_RIGHTS transfer, kacs_open_peer_token, or any other path.
3.6 Binary Signature Verification
Peios / Advanced Peios / 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.
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_SIGNING_CRYPTO_UNAVAILABLE KMES event
carrying the errno. 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 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.8 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 / Advanced Peios / 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. Entries stricter than a metadata
query, such as /proc/<pid>/stack, keep their native hardening and
are not brought under the metadata rule.
Denying access prevents reading inside /proc/<pid>/ but does not
hide the PID: the directory name is still visible through getdents.
Visible-but-inaccessible is the accepted position.
/proc is not FACS-managed — it is a virtual filesystem with no
backing store and no xattrs — so enforcement there happens through
direct kernel checks rather than an object-backed FACS path.
Capability metadata. capget() on the current process, or on a
thread sharing its security state, is not a boundary operation.
Against another process it is a detailed information query needing
PROCESS_QUERY_INFORMATION plus dominance.
Resource limits, scheduler and placement. Read-only prlimit
needs PROCESS_QUERY_INFORMATION plus dominance; a limit change needs
PROCESS_SET_INFORMATION plus dominance. setpgid() needs
PROCESS_SET_INFORMATION; getpgid() and getsid() need
PROCESS_QUERY_LIMITED; the scheduler, affinity and I/O priority
queries need PROCESS_QUERY_INFORMATION; and the memory-placement
mutations Linux routes through task_movememory need
PROCESS_SET_INFORMATION. Setting nice, scheduler parameters and I/O
priority all need PROCESS_SET_INFORMATION too. Self-directed
versions of all of these are not boundary operations and skip both
checks.
CPU affinity is per-thread, so changing the caller's own thread or
a sibling in the same process is not a boundary operation. Changing a
thread in a different process needs PROCESS_SET_INFORMATION plus
dominance plus SeIncreaseBasePriorityPrivilege — and the privilege
is checked and marked used before the descriptor and dominance call,
so the SeDebugPrivilege rescue cannot substitute for it. KACS does
not relax the kernel's native affinity validity rules: an invalid or
disallowed mask still fails.
Token opens. kacs_open_process_token and
kacs_open_thread_token need PROCESS_QUERY_INFORMATION plus
dominance. Reading a process's security identity is as sensitive as
reading its memory.
Performance monitoring. Target-specific perf_event_open() on
another process can leak execution timing, branch prediction
behaviour, cache access patterns and instruction traces — side
channels that reveal cryptographic keys. It needs
SeProfileSingleProcessPrivilege plus PROCESS_QUERY_INFORMATION
plus dominance, with the privilege again checked first so the debug
rescue cannot stand in for it. Own-task profiling is not a boundary
operation and needs no privilege. System-wide profiling, pid == -1,
samples every task on a CPU including protected ones, so it needs the
operator-class SeSystemProfilePrivilege. Cgroup perf mode stays
under Linux's native model. Because the target task is resolved after
the stock security_perf_event_open hook fires, this rule is enforced
through a target-resolved syscall patch rather than that hook — there
is no security_perf_event_open registration at all.
3.7.4 The PeiosTcb floor on kernel-initiated execs #
One place PIP gates execution rather than merely labelling it. It is
not a dominance test — there is no caller to compare against — but a
threshold: a binary the kernel execs on its own behalf must carry at
least PeiosTcb trust, or the exec fails with EACCES.
The case that motivates it is request_module(). When the kernel needs
a module it does not have — get_fs_type() on a mount, socket() for
an unknown protocol family, the crypto API resolving a name — it spawns
CONFIG_MODPROBE_PATH as a usermode helper and runs it at the kernel's
own authority. That path is a writable sysctl, which makes redirecting
/proc/sys/kernel/modprobe a classic escalation: point it somewhere
attacker-controlled and the next module request executes it with the
kernel behind it. The floor makes the redirection worthless on its own,
because the attacker would also have to produce a TCB-signed binary.
This is the only exec KACS refuses on integrity grounds. Everywhere else an unsigned binary runs and simply carries no tier, because a requesting process's own authority bounds what it can do. A kernel-initiated exec has no such process behind it, so there is no lesser authority to fall back to.
The floor also refuses when no tier could be derived at all, not only when one was derived and graded too low. Treating "could not establish trust" differently from "is not trusted" would leave the check bypassable by whatever prevented the derivation from running.
Nothing upstream identifies such an exec by the time the LSM sees it.
The helper child is created by user_mode_thread(), so it never
carries PF_KTHREAD — kernel_execve() rejects kernel threads
outright — and security_kernel_module_request() fires in the
requesting task, before the child exists. So kernel/umh.c is
patched to mark the child after commit_creds() and before
kernel_execve(), and the mark is read in the bprm_creds_from_file
hook. It lives in the KACS task blob rather than costing a
task_struct flag.
The mark is never cleared. A helper that re-execs — an interpreter for
a #! helper — stays under the floor rather than escaping it on the
second exec; the task exists only to be that helper.
A refusal emits kacs_exec with reason umh-not-tcb, so it is
visible rather than presenting as an unexplained module-load failure.
3.7.5 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.6 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.7 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.8 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 / Advanced Peios / 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 any node's staged granted mask differs from that node's effective granted mask, or when staged auditing differs from effective auditing. 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 / Advanced Peios / 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 / Advanced Peios / 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 #
| Bit | Value | Meaning |
|---|---|---|
SYSTEM_MANDATORY_LABEL_NO_READ_UP | 0x00000001 | Non-dominant callers receive no read-mapped rights from the DACL. |
SYSTEM_MANDATORY_LABEL_NO_WRITE_UP | 0x00000002 | Non-dominant callers receive no write-mapped rights from the DACL. |
SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP | 0x00000004 | Non-dominant callers receive no execute-mapped rights from the DACL. |
Unknown bits in a label mask are ignored.
3.8.3.4 The algorithm #
EnforceMIC(ace, token, mapping, &decided):
if not (token.mandatory_policy & NO_WRITE_UP):
return
token_dominates = (token.integrity_level >= ace.integrity_level)
if token_dominates:
return
// Non-dominant: start with read + execute, strip per label policy.
allowed = MapGenericBits(GENERIC_READ, mapping)
| MapGenericBits(GENERIC_EXECUTE, mapping)
if ace.mask & SYSTEM_MANDATORY_LABEL_NO_READ_UP:
allowed &= ~MapGenericBits(GENERIC_READ, mapping)
if ace.mask & SYSTEM_MANDATORY_LABEL_NO_WRITE_UP:
allowed &= ~MapGenericBits(GENERIC_WRITE, mapping)
if ace.mask & SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP:
allowed &= ~MapGenericBits(GENERIC_EXECUTE, mapping)
// READ_CONTROL and SYNCHRONIZE are always allowed regardless of the
// object type's GenericMapping and the label's up-strip policy — a
// non-dominant caller can always read the descriptor and synchronize
// on the object. Applied after the strips because a file GENERIC_READ
// mapping folds these bits in, so NO_READ_UP would otherwise take them.
allowed |= READ_CONTROL | SYNCHRONIZE
// SeRelabelPrivilege: let WRITE_OWNER through MIC.
if token.privilege_enabled(SeRelabelPrivilege):
allowed |= WRITE_OWNER
all_bits = MapGenericBits(GENERIC_ALL, mapping)
decided |= all_bits & ~allowed
3.8.4 Restricted Tokens
Peios / Advanced Peios / 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 / Advanced Peios / 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; and 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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. 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. 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 / Advanced Peios / 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: access-audit for object-access events from the SACL
walk and from token audit-policy forcing, and privilege-use for
privilege-use events. A third family, caap-policy-diagnostic, is
emitted for the CAAP conditions of §3.8.8 — a SACL evaluation error,
or 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.
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
continuous-audit 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.
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_audit_context the caller supplied. 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 — user SID, group SIDs, integrity level, PIP identity; the object, as the caller-provided context; the access, meaning what was requested, what was granted, and whether the request succeeded; the trigger, which audit ACE matched or which privilege was exercised; and the process, its PID, name and executable path.
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.10 The Algorithm
Peios / Advanced Peios / PKM / KACS / AccessCheck
The preceding sections describe what each layer of AccessCheck does. This one describes how they compose, and is the definitive statement of the evaluation order.
3.8.10.1 Pipeline overview #
Before the pipeline proper, two things are checked and can fail the
call outright. The token's own invariants are validated — a
write_restricted token without user_deny_only is rejected as
invalid — and, in the orchestrator, a null descriptor is rejected and
AccessCheckResultList is required to have been given an object type
list. A null descriptor presented by an Identification-level token
therefore fails as an invalid parameter rather than as access denied,
because the null check runs first.
The pipeline then runs in this order:
- 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 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 #
| Variable | Set at | Meaning |
|---|---|---|
security_granted | Step 4 | SeSecurityPrivilege granted ACCESS_SYSTEM_SECURITY. |
backup_granted | Step 4 | SeBackupPrivilege granted read bits. |
restore_granted | Step 4 | SeRestorePrivilege granted write and metadata bits. |
take_ownership_granted | Step 9 | SeTakeOwnershipPrivilege granted WRITE_OWNER. |
relabel_granted | Step 5 | SeRelabelPrivilege added WRITE_OWNER to the MIC allowed set. |
Each records which bits that privilege contributed, and step 13 compares each against the requested mask and the final result.
3.9.1 The Handle Model
Peios / Advanced Peios / 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: FAT, exFAT, NFS client mounts, ISO9660,
and cgroup2. StrataFS is fixed at facs_deny_missing. Everything else
— tmpfs, squashfs, ext4, btrfs — defaults to facs_deny_missing
unless a trusted policy agent adopts it.
Userspace cannot set a superblock to unmanaged through the public
ABI; the class is reserved for the kernel classifier and the hardcoded
rules. Attempting to set a policy on a magic-derived unmanaged
superblock fails with EOPNOTSUPP, and so does attempting to change
StrataFS's.
3.9.1.2 Scope #
The sole-authority claim covers local FACS-managed filesystems. It
does not cover O_PATH descriptors, which are not managed; NFS client
mounts, which are ephemeral-synthesising and retain dual authority
with the server; /proc, which is unmanaged and PIP-protected; or
/sys, which is unmanaged and carries a hardcoded rule instead —
writes there require Administrators or SYSTEM, enforced against a
built-in descriptor. Descriptors for processes obtained through
pidfd bypass FACS at open as well.
3.9.1.3 Handle acquisition #
A descriptor carries its granted mask through every acquisition path,
and transfer is an intentional capability-delegation mechanism.
dup/dup3 and fork produce the same open file description and
therefore the same rights; descriptors without FD_CLOEXEC survive
exec unchanged; SCM_RIGHTS transfers the descriptor as a capability
token, with possession as authorization and no re-check of the
receiver's identity; and pidfd_getfd() is gated by an AccessCheck
for PROCESS_DUP_HANDLE against the target process, after which the
caller receives the descriptor with its full mask.
The security boundary is at open time. Every subsequent transfer carries the mask unchanged.
This means mandatory subject policy — MIC and PIP — is evaluated once, at open, against the opener. A high-integrity process that opens a file and passes the descriptor to a low-integrity one has effectively delegated its access. That is the handle model: authority is on the handle, not on the holder.
3.9.1.4 Stacked backing files #
When a stacking filesystem creates a kernel-private backing file for a managed user-visible file, the outer file's granted and continuous audit masks are captured into the backing-file blob, and the backing file's ordinary blob receives that exact snapshot when the provider is opened. No new AccessCheck runs against the task executing the stacking filesystem.
The inheritance is deliberately narrow. It is admitted only through
the kernel's typed backing-file allocation path, only from a managed
non-O_PATH outer file, and it retains no reference to that outer
file — only the values. It is not a general snapshot-cloning
mechanism.
An active StrataFS copy-up context takes precedence: a backing open that does not match its exact object and phase does not fall back to inherited authority. When an exactly-bound copy-up backing file is adopted by the outer descriptor, the outer snapshot is installed into both blobs as a single transition, after which the backing file behaves like any other stacked open.
The user-visible operation is checked and continuously audited once,
against the outer handle. Immediate provider re-entry through
security_file_permission or security_mmap_file neither repeats the
caller authorization nor emits a second caller audit event. The
backing file keeps the snapshot for later descriptor-local
enforcement, including mprotect() after a stacked mmap, and the
mmap_backing_file handoff verifies that the outer, backing and
captured snapshots still agree before the provider mapping is
installed.
This suppresses duplicate KACS authorization only. It bypasses nothing else — not another LSM, not filesystem errors, not read-only mounts, immutable state, quota, space, I/O, or format validation.
3.9.2 KACS-Native Open
Peios / Advanced Peios / 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 #
| Value | Name | Description |
|---|---|---|
| 0x0001 | KACS_CREATE_OPT_DIRECTORY | The target has to be a directory. When creating, create a directory rather than a regular file; when opening an existing non-directory, fail with ENOTDIR. |
| 0x0002 | KACS_CREATE_OPT_DELETE_ON_CLOSE | Delete the file when the last handle in its lineage closes. Requires DELETE on the file or FILE_DELETE_CHILD on the parent. |
All other bits are reserved and have to be zero; a nonzero reserved
bit fails with EINVAL.
Delete-on-close is deliberately no-share. kacs_open_how carries
no share-mode field, so the Windows FILE_SHARE_DELETE compatibility
matrix is not representable in the frozen ABI, and the kernel contract
is bounded instead. The obligation attaches to one ordinary file
description lineage rather than to Linux inode last-reference
semantics, so dup(), fork() and SCM_RIGHTS all preserve it
because they preserve the description. Once a lineage exists for a
file object, later opens of that object fail closed rather than
emulating share-mode compatibility. The unlink happens at final close
of that lineage — not at open, and not at generic inode
last-reference drop — and if the pathname is already gone by then the
close path treats it as a no-op rather than a new error. Regular files
only: directory delete-on-close fails closed.
3.9.2.6 Caller-supplied descriptors #
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 / Advanced Peios / 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 / Advanced Peios / 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 #
| Operation | Required right |
|---|---|
| Read | FILE_READ_DATA |
| Sequential write, no append intent | FILE_WRITE_DATA |
Append-intent write (O_APPEND descriptor or RWF_APPEND) | FILE_APPEND_DATA or FILE_WRITE_DATA |
| Positioned write, or a no-append override | FILE_WRITE_DATA — denied on append-only descriptors |
| Directory listing | FILE_LIST_DIRECTORY |
ftruncate | FILE_WRITE_DATA |
fallocate allocation (ALLOCATE_RANGE, with or without KEEP_SIZE) | FILE_APPEND_DATA or FILE_WRITE_DATA |
fallocate mutation (PUNCH_HOLE, ZERO_RANGE, COLLAPSE_RANGE, INSERT_RANGE, UNSHARE_RANGE, WRITE_ZEROES) | FILE_WRITE_DATA |
mmap PROT_READ | FILE_READ_DATA |
mmap PROT_WRITE | MAP_SHARED | FILE_WRITE_DATA — FILE_APPEND_DATA alone is insufficient |
mmap PROT_WRITE | MAP_PRIVATE | FILE_READ_DATA — copy-on-write, no write to the file |
mmap PROT_EXEC | FILE_EXECUTE |
mprotect | As mmap, for the new protection flags |
flock LOCK_SH / F_RDLCK | FILE_READ_DATA |
flock LOCK_EX / F_WRLCK | FILE_WRITE_DATA or FILE_APPEND_DATA |
fsync / fdatasync | SYNCHRONIZE |
A fallocate mode outside the supported set fails closed, and
PUNCH_HOLE additionally requires KEEP_SIZE.
3.9.4.2 Metadata operations #
| Operation | Required right |
|---|---|
stat / lstat / path statx | FILE_READ_ATTRIBUTES |
fstat / descriptor statx | FILE_READ_ATTRIBUTES |
fstatfs | FILE_READ_ATTRIBUTES |
path and descriptor file_getattr | FILE_READ_ATTRIBUTES |
path and descriptor file_setattr | FILE_WRITE_ATTRIBUTES |
truncate by pathname | FILE_WRITE_DATA |
chmod / fchmodat / fchmod | WRITE_DAC |
chown / lchown / fchownat / fchown | WRITE_OWNER |
utimensat / utimes / futimens | FILE_WRITE_ATTRIBUTES |
getxattr / lgetxattr / fgetxattr | FILE_READ_EA |
setxattr / lsetxattr / removexattr / fsetxattr / fremovexattr | FILE_WRITE_EA |
listxattr / llistxattr / flistxattr | none |
access / faccessat F_OK | FILE_READ_ATTRIBUTES |
access / faccessat R_OK | FILE_READ_DATA |
access / faccessat W_OK | FILE_WRITE_DATA |
access / faccessat X_OK | FILE_EXECUTE |
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 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.5 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.6 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.7 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 / Advanced Peios / 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.
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.
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.
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 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.
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 #
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.
The call requires enabled SeTcbPrivilege and marks it used. The
public ABI accepts only the three managed classes; unmanaged,
unknown values, nonzero reserved flags and malformed arguments all
fail closed.
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.
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 #
Synthesis runs holding the FACS inode lock, and writing the xattr
takes the inode's i_rwsem. Doing that inline would acquire i_rwsem
under the FACS lock, inverting the order the access path requires, and
would self-deadlock when synthesis is reached from a metadata
operation whose VFS caller already holds i_rwsem. 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 next cache miss reads it back as an ordinary xattr-backed descriptor: durable, no longer generation-tagged, and never synthesised again.
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 / Advanced Peios / 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.
| Flag | Component | Required right |
|---|---|---|
OWNER_SECURITY_INFORMATION | Owner SID | WRITE_OWNER |
GROUP_SECURITY_INFORMATION | Group SID | WRITE_OWNER |
DACL_SECURITY_INFORMATION | Discretionary ACL | WRITE_DAC |
SACL_SECURITY_INFORMATION | System ACL | ACCESS_SYSTEM_SECURITY |
LABEL_SECURITY_INFORMATION | Mandatory integrity label | WRITE_OWNER, plus the integrity constraints below |
The blob is validated structurally — parseable, well-formed ACEs, valid SIDs, at most 65535 bytes — and then only the indicated components are merged into the existing descriptor. 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.
3.9.6.1 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.2 Integrity labels #
Without SeRelabelPrivilege a caller may set a label only at or below
its own integrity level; with it, any level.
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.
3.9.6.3 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.4 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.5 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 / Advanced Peios / PKM / KACS / FACS
StrataFS copy-up is the internal realisation of an operation already authorized against a StrataFS handle — not a second caller-requested operation. KACS therefore provides a kernel-internal context so that the mechanics of copy-up introduce no new rights checks against the task that happens to execute them.
The context exempts KACS caller authorization only. It does not
replace credentials, borrow an identity, grant a privilege, bypass
another LSM, neutralise an underlying filesystem check, make a
read-only mount writable, or suppress immutable, append-only, quota,
space, I/O or format errors. Every exemption is a return before the
authorize call, and every mutation still goes through the ordinary
vfs_* path under mnt_want_write().
3.9.7.1 Admission and lifetime #
Only the in-kernel StrataFS implementation can create or enter a context. There is no userspace surface of any kind — no ABI, file descriptor, token, ioctl, syscall or securityfs control — and the copy-up API is declared in a kernel-private header with no exported symbols.
A context is created only after the StrataFS operation requiring copy-up has passed its complete outer authorization. KACS does not verify that: the context creation call performs no check, and the ordering is satisfied by StrataFS calling in the right order. The context is not itself an alternative authorization path.
The exemption covers namespace creation the outer handle authorises
without an add-entry right on the create stratum, so the context is
reachable only from a create-enabled mount established by a caller
holding CAP_SYS_ADMIN in the initial user namespace. That
closure rests on an explicit test of the mounter's user namespace at
stack establishment — the capability half contributes nothing to it,
because the KACS switchboard discards the target namespace and answers
from SeTcbPrivilege alone (§3.10.2). The check is made when the
immutable stack is established rather than when a copy-up runs, so
descriptor delegation cannot reintroduce acting-task authorization.
Reconfiguration cannot re-supply the strata list.
A context attaches to at most one task, and a task carries at most
one. It is not inherited by fork(), clone() or execve() — exec
explicitly clears it. A refcounted context can be transferred to a
kernel worker, but the originating task leaves it first. Leaving,
task exit, every error path, and completion all remove the attachment
and clear any armed phase.
The interface fails closed on nesting, concurrent attachment, a stale phase, or an object mismatch. A mismatched operation is evaluated normally and neither consumes nor broadens the armed exemption.
3.9.7.2 Object and phase binding #
Creation pins the exact provider path, its current inode, a complete
validated copy of its effective descriptor, and the provider-visible
security.capability value or its absence. Every later positive path
is paired with a pinned inode as well — retaining a dentry alone is
insufficient, because an unlink followed by recreation can
reinstantiate it over a different inode.
One phase is armed at a time:
| 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 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.10.1 Credential Projection
Peios / Advanced Peios / 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.
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 / Advanced Peios / 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. |
PRIVILEGE capabilities gate operations no other KACS hook covers, and map to a KACS privilege:
| CAP | Name | Privilege |
|---|---|---|
| 9 | CAP_LINUX_IMMUTABLE | SeTcbPrivilege |
| 10 | CAP_NET_BIND_SERVICE | SeBindPrivilegedPortPrivilege |
| 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_manage_volumes(), which
accepts SeManageVolumePrivilege or SeTcbPrivilege, before falling
back to the ordinary CAP_SYS_ADMIN check. 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.
CAP_SYS_BOOT carries an extra condition the table cannot express: a
token whose logon session is of a remote origin — Network,
NetworkCleartext or NewCredentials — 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: landlock, lockdown, yama and integrity make no identity-based access decisions and stack safely.
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 / Advanced Peios / 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).
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.
Appendix 3.A KACS ABI Reference
Peios / Advanced Peios / 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/.
| Number | Constant | Signature |
|---|---|---|
| 1000 | SYS_KACS_OPEN_SELF_TOKEN | kacs_open_self_token(unsigned int flags, u32 access_mask) |
| 1001 | SYS_KACS_OPEN_PROCESS_TOKEN | kacs_open_process_token(int pidfd, u32 access_mask) |
| 1002 | SYS_KACS_OPEN_THREAD_TOKEN | kacs_open_thread_token(int pidfd, int tid, u32 access_mask) |
| 1003 | SYS_KACS_CREATE_TOKEN | kacs_create_token(const void __user *spec, size_t spec_len) |
| 1004 | SYS_KACS_CREATE_LOGON_SESSION | kacs_create_logon_session(const void __user *spec, size_t spec_len) |
| 1005 | SYS_KACS_SET_PSB | kacs_set_psb(int pidfd, u32 mitigations) |
| 1006 | SYS_KACS_DESTROY_EMPTY_LOGON_SESSION | kacs_destroy_empty_logon_session(u64 auth_id) |
| 1010 | SYS_KACS_OPEN_PEER_TOKEN | kacs_open_peer_token(int sock_fd) |
| 1011 | SYS_KACS_IMPERSONATE_PEER | kacs_impersonate_peer(int sock_fd) |
| 1012 | SYS_KACS_REVERT | kacs_revert(void) |
| 1013 | SYS_KACS_SET_IMPERSONATION_LEVEL | kacs_set_impersonation_level(int sock_fd, u32 level) |
| 1020 | 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 | 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 | 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 | kacs_access_check(const void __user *uargs) |
| 1024 | 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 | 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 | kacs_get_mount_policy(int fd, struct kacs_mount_policy_args __user *uargs, size_t argsize) |
| 1027 | 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 |
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 |
Token-handle ioctls.
| 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).
| 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) |
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) |
KACS_SE_BIND_PRIVILEGED_PORT_PRIVILEGE | 0x8000000000000000 (9223372036854775808) |
3.A.4 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) |
Claim attribute flags.
| 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.5 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) |
kacs_open_how.flags bits.
| 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.6 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.7 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. 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.8 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.9 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.10 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_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_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 |
KACS_SOCK_IMPERSONATE | 16 | impersonate peer result |
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 | continuous-audit 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 |
Appendix 3.B Departures from MS-DTYP
Peios / Advanced Peios / 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.
| Area | Departure | Why |
|---|---|---|
Conditional ACE @Local. | Resolved from an AccessCheck parameter rather than a token field | The context is per-call and varies between checks. |
| Virtual groups in expressions | Member_of({S-1-3-4}) returns true for the owner | Keeps the SID matcher and the expression evaluator semantically consistent. |
| INT64/UINT64 promotion | Relational operators promote between the two | Without promotion, UINT64 claims cannot be used in conditions at all. |
Member_of filtering | Filtered by ACE polarity, so deny-only groups do not satisfy allow-ACE conditions | Consistent with deny-only group semantics everywhere else. |
Exists scope | Extended to all four attribute namespaces | No reason to restrict existence tests to Local and Resource. |
| ACE mask mapping | ACE masks are mapped through GenericMapping at evaluation time | Required for GENERIC_ALL in central access policy recovery ACEs (§3.8.8). |
MAXIMUM_ALLOWED | First-writer-wins for targeted and maximum-allowed requests | Eliminates disagreement between "what can I do?" and "can I do this?" on a non-canonically ordered DACL. |
| Zero desired mask | Succeeds rather than returning access denied | "Asked for nothing, got nothing" is a valid answer. |
| Alarm ACEs | Repurposed for continuous per-operation auditing (§3.8.9) | Reserved but never implemented in the reference model. |
| Multiple scoped policy ACEs | Several permitted per SACL | AND semantics make composition safe. |
| Mandatory policy mutability | mandatory_policy is immutable on the token (§3.2.2) | A mutable policy reduces MIC to advisory. |
| Impersonation integrity ceiling | Enforced unconditionally; SeImpersonatePrivilege does not bypass it (§3.5.2) | MIC is a real boundary precisely because the mandatory policy is immutable. |
| Impersonation origin check | Dropped | Eliminates hidden impersonation paths. |
| PIP determination | Kernel-only, from the binary signature, with no parent input (§3.3.2) | One input, one answer, no ambiguity. |
| Object type list validation | Duplicate GUIDs and level gaps rejected (§3.8.5) | Prevents node lookup returning the wrong node and propagation becoming undefined. |
| Composite equality | Element-wise ordered comparison | Never over-grants. |
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 / Advanced Peios / PKM / KACS
KACS emits its audit records through KMES with origin class 2 (§2.2).
Each event's payload is a msgpack map; the shared subject and
process sub-maps are attached at emission time from the resolved
call context rather than by the evaluation pipeline (§3.8.9).
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.
3.C.1 Event families #
| Event type | Emitted by |
|---|---|
access-audit | The SACL walk, and token audit-policy forcing. |
continuous-audit | Enforcement points, per operation, against a handle's continuous audit mask. |
privilege-use | Privilege-use auditing, for the five AccessCheck-influencing privileges. |
caap-policy-diagnostic | A CAAP rule SACL error, or a staged-versus-effective mismatch. |
logon-session-destroyed | LogonSession teardown (§3.2.7). |
corrupt-sd | A descriptor xattr that exists but fails structural validation (§3.9.5). |
STRATAFS_COPY_UP | StrataFS copy-up lifecycle and failure (§3.9.7). |
STRATAFS_MUTATION_REFUSED | A StrataFS arrangement refusal. |
The privilege field of a privilege-use event carries a canonical
name, and only five are representable — SeSecurityPrivilege,
SeTakeOwnershipPrivilege, SeBackupPrivilege, SeRestorePrivilege
and SeRelabelPrivilege. Any other bit fails the encoder closed
rather than emitting an unnamed privilege, which is consistent with
those being the only five that can produce such an event at all
(§3.4.1).
continuous-audit carries an operation 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. Its object_context field is always nil.
3.C.2 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:
logon-session-destroyed where the authentication package name is not
valid UTF-8; the two StrataFS events on an allocation failure or an
over-long operation string; and any self-emitted payload that would
exceed its encoding buffer.
The transport itself — ring buffer delivery, buffering and drop accounting — is KMES's (§2.5, §2.7).
Appendix 3.D KACS ABI Notes
Peios / Advanced Peios / 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 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.
| Class | Payload |
|---|---|
USER | Bare SID. |
GROUPS | SID array. |
PRIVILEGES | 32 bytes: present, enabled, enabled-by-default and used, four u64 in that order. |
TYPE | u32, 4 bytes. |
INTEGRITY_LEVEL | The mandatory-label SID S-1-16-<level>, 12 bytes. |
OWNER | Bare SID, resolved through the owner index: 0 is the user SID, N is groups[N-1]. |
PRIMARY_GROUP | Bare SID, resolved the same way. |
INTERACTIVITY_SCOPE | u32, 4 bytes. |
RESTRICTED_SIDS | SID array; count 0 on an unrestricted token. |
SOURCE | 16 bytes: an 8-byte name followed by a u64 LUID. |
STATISTICS | 40 bytes: token id, LogonSession id, modified id, token type, a reserved zero, and expiration. |
ORIGIN | u64, 8 bytes. |
ELEVATION_TYPE | u32, 4 bytes. |
DEVICE_GROUPS | SID array. |
APPCONTAINER_SID | Bare SID; empty when the token is unconfined. |
CAPABILITIES | SID array. |
MANDATORY_POLICY | u32, 4 bytes. |
LOGON_TYPE | u32, 4 bytes, read from the LogonSession. |
LOGON_SID | Bare SID, derived from the LogonSession id. |
DEFAULT_DACL | Binary ACL; empty when none is set. |
IMPERSONATION_LEVEL | u32, 4 bytes. |
USER_CLAIMS | Claims array. |
DEVICE_CLAIMS | Claims array. |
PROJECTED_SUPPLEMENTARY_GIDS | [count:u32le] followed by count u32 GIDs. |
Nine token fields have no query class at all: created_at,
token_guid, 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.3 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.4 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.5 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 enforced at initialisation rather than only at build (§3.7).
CONFIG_SECURITY_SELINUX, _APPARMOR, _SMACK and _TOMOYO are
refused by Kconfig dependency; CONFIG_BPF_LSM is refused only at
runtime, so a kernel enabling both configures and builds and then
fails to initialise. CONFIG_LSM is never parsed.
Two further symbols gate large bodies of code:
CONFIG_SECURITY_PKM_KUNIT, which compiles in the test harness and,
in the signing path, a different and publicly known verification key
(§3.6); and CONFIG_STRATAFS_FS, without which the copy-up API of
§3.9.7 is inert.
4.1 Overview
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / PKM / stratafs / Strata and Mounting
stratafs registers exactly one filesystem-specific mount parameter,
strata, and rejects every other name. There is no option to select a
security descriptor, an access-check behaviour, an inode-numbering
scheme, or a caching mode.
strata=<stratum>[:<stratum>]...
<stratum> := <path>[+<flag>]...
<flag> := create | ro | am
Strata are separated by : and given highest-precedence first. Each
stratum is an absolute path, optionally followed by flags, each
introduced by +.
strata=/system/retc:/lcl/etc+create:/usr/etc+ro
: separates strata rather than , because a mount options string is
itself comma-separated when passed through the legacy mount interface,
which would otherwise split the value.
4.2.2.1 Escaping #
Within a path, a literal :, +, , or \ is escaped by a preceding
\. Those four characters are the entire escapable set; the escaped
byte is stored literally, and the backslash is consumed.
Because the legacy mount(2) data is one comma-separated string,
stratafs replaces the VFS's monolithic option splitter with one that
honours backslash escapes, so that an escaped comma inside a stratum
path survives the split rather than being read as an option boundary.
4.2.2.2 Parse failures #
Every malformed value is refused with EINVAL. The parser consumes the
whole string and errors on any byte it cannot classify; there is no
skip-and-continue path, so nothing it does not understand is silently
ignored.
| Condition | |
|---|---|
The strata= option is absent | There is no default stack |
| Its value is empty | |
| An element between two separators is empty, or the value begins or ends with a separator | |
| A path is not absolute | Tested on the raw first byte, which is exact since / is not escapable |
| A path is empty after unescaping | Defensive; the absolute-path test already guarantees one byte |
An unescaped , appears in a path, or inside a flag token | The option string is comma-separated at the outer level |
A \ appears at the end of the value, or before a character that is not :, +, , or \ | A dangling or meaningless escape |
A + is followed by no flag, or by an unrecognised one | Flag names are matched by exact length and content |
| The same flag appears more than once on one stratum | |
| More than 16 strata | The array bound is reached mid-parse |
strata= appears twice in one option string |
Two paths return ENOMEM rather than EINVAL, both allocation
failures during parsing. Nothing bounds a stratum path at parse time:
an over-long one is accepted here and fails later with ENAMETOOLONG
when the joined path exceeds PATH_MAX during a resolution.
The stack-wide conditions — an empty stack, two create strata, a
stratum carrying both create and ro — are checked separately, after
parsing and before any path is resolved. They depend on nothing but the
option string, so they are reported whatever the caller's access
(§4.2.3).
4.2.2.3 Generic mount flags #
Generic flags apply as they do to any filesystem, with one addition. A
stratafs mount may be mounted read-only, and the superblock's read-only
state is the first term of the routing decision, short-circuiting
before the provider or the create stratum is considered at all. It
therefore refuses every mutation with EROFS regardless of the stratum
stack, and is both independent of and stricter than a stack with no
create stratum.
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:
any remount that supplies strata= at all is refused with EINVAL.
The check is on the presence of the parameter rather than on its value, so a remount that replays the current stack byte-for-byte is refused too — which matters, because that is what a tool reconstructing options from the mount table will do.
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 / Advanced Peios / 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, so an unprivileged
mount in a user namespace is permitted; what the caller needs is only
the access that resolving the stratum paths already requires, which
falls out of the resolution itself.
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 #
| Condition | Error |
|---|---|
| The stratum stack is empty | EINVAL |
| The stack exceeds 16 strata | EINVAL |
More than one stratum carries create | EINVAL |
A stratum carries both create and ro | EINVAL |
| The same directory appears as more than one stratum | EINVAL |
A malformed strata= value (§4.2.2) | EINVAL |
A create-bearing stack from outside the initial user namespace, or without CAP_SYS_ADMIN there | EPERM |
| A stratum's path names something other than a directory | ENOTDIR |
A stratum's directory is absent and the stratum does not carry am | ENOENT |
| The composed stack reaches the kernel's maximum stacking depth | ELOOP |
| A stratum lies within the mount point, or within another stratafs mount whose strata include this mount point | ELOOP |
| Sixteen consecutive collisions allocating a mount cookie | EAGAIN |
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.
For a single stratum, that holds. The path walk runs under the caller's
credentials, so a path the caller cannot resolve returns EACCES from
the walk itself, before the type test or the duplicate test is reached,
and the EACCES is propagated unchanged.
Across the stack it does not. The strata are checked in one loop —
resolve, stat, type-test, compare against earlier strata — so stratum 0
is fully judged before stratum 1 is resolved at all. A caller who names
a readable stratum first and an unreadable one second learns the first
stratum's ENOTDIR or ENOENT rather than the EACCES they would
have been given had the whole stack been checked for entitlement first.
This is tracked as a defect; the disclosure is bounded to paths the
caller could resolve, but the specified ordering is stack-wide.
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).
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.
4.2.4 Absent Strata
Peios / Advanced Peios / 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.
One piece of state does survive the gap, though it is not resolution memory. The inode-number identity map (§4.4.3) is keyed on the provider inode object and pins it for the life of the mount, so if the same underlying inode is reached again — because the directory was renamed away and back rather than replaced — it receives the same inode number it had before. That is exactly what the identity rule requires: equal numbers for one provider object, whatever path reached it.
4.3.1 Lookup
Peios / Advanced Peios / 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.
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 / Advanced Peios / 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.
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. Building the participant set for a merged directory follows the
final component, and so do the emptiness scan, the foreign-entry scan,
the permission check and directory fsync.
The consequence is that a stratum holding a name as a symlink to a directory participates in the merged directory, contributing the target's entries to the merged listing and to emptiness tests, while a stratum holding a dangling symlink at that name participates in resolution but not in the participant set. Whether that is intended is an open question against the specification, which describes a stratum holding a non-directory as masked; it is tracked as a defect.
4.3.3 Type Conflicts
Peios / Advanced Peios / 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.
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 / Advanced Peios / 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 mapping
the result through the identity table (§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.
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 / Advanced Peios / 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 / Advanced Peios / 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 two internal counters the superblock does carry — the inode-number allocator and the staging-name counter — are consulted by nothing in this path.
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.
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 / Advanced Peios / 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, so there is no path around it.
The inode number is allocated, not derived. A per-mount monotone
counter hands out a number for each distinct provider inode the first
time it is seen, and the pair is recorded in an xarray on the
superblock keyed on the provider inode, holding a reference on it so
the object cannot be freed and its address reused while the mount
lives. The counter starts at 1 and is pre-incremented, so the first
number handed out is 2. The provider's own inode number and device are
never read for this purpose.
That satisfies the identity requirements — two names compare equal exactly when they resolve to one provider object, since the map is keyed on the object itself and not on the stratum that reached it, so hard links compare equal and one directory reached through two strata compares equal too. It does not follow the specification's advice to derive the number from the provider's, and it therefore pays the cost that advice exists to avoid: the map is never evicted from, and it pins every provider object ever reached through the mount until unmount. A mount whose strata would not have provoked the fallback pays it anyway.
Two mechanical details. The map key is the provider inode's kernel
address shifted right by three; a collision would be caught by the
stored back-pointer and turned into an allocation failure rather than a
false equality, so it fails rather than lying. And the stored number
and i_ino are unsigned long while the counter is 64-bit, so on a
32-bit build the number truncates.
4.4.3.1.1 The mount root #
The root inode is created once, when the superblock is filled, and its
number is never recomputed — d_revalidate returns 1 for the root, so
it is never replaced. Its provider is re-resolved live on every use,
so the root's mode, owner and timestamps are always current; only the
number is not.
When the root's provider changes — a higher-precedence stratum root
appears, or the mount-time one is removed — stat on the mount point
keeps reporting the number allocated for the mount-time provider. Where
no stratum root existed at mount, it reports a bare counter value
corresponding to no provider object at all. If the root's current
provider is also reachable at some other merged path, that path reports
the object's mapped number while the root reports its stale one, so two
paths naming one object disagree. This is tracked as a defect.
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.
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.
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.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 allocated for the
pre-copy-up provider, while a fresh resolution of the path 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, and the identity map a third, held until unmount.
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 / Advanced Peios / 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.
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
returns one of three routes.
- 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.
| Operation | Routes |
|---|---|
| Writing or appending | Yes |
Truncating, or any other setattr | Yes |
| Changing mode, owner, timestamps, or the security descriptor | Yes |
| Setting or removing an extended attribute | Yes |
fallocate | Yes |
splice into the file | Yes |
copy_file_range and remap_file_range | Yes |
| Establishing a shared writable mapping | Yes |
| Reading contents, attributes, or extended attributes | No |
| Opening, for any access | No |
| Taking or releasing a lock, or a lease | No |
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 / Advanced Peios / PKM / stratafs / Mutation
Copy-up replicates an object from its provider stratum into the create stratum, at the same path relative to the mount root, so that a modification can be applied without modifying the provider.
Every step runs inside a KACS copy-up context, which exempts the mechanics from caller authorisation without granting anything. §3.9.7 describes that context in full; §4.6.3 covers the stratafs side of the bargain.
4.5.2.1 Parents #
Where the create stratum does not hold the directories containing the object's path, they are created first, walking the relative path component by component from the create-stratum root downwards. Each is created as an empty directory with the mode of the corresponding merged provider directory, and with that directory's security descriptor, installed by KACS during the create phase rather than inherited.
Contents are not copied. The directories exist to hold the copied object; the entries they hold in lower strata continue to be reached by merging.
Where the create stratum holds one of those components as something
other than a directory, the copy-up fails with ENOTDIR and the
operation requiring it fails. The blocking entry is not removed or
replaced — there is no unlink, rmdir or rename anywhere on the
parent-materialisation path.
Materialised parents receive a mode and a descriptor, and nothing else: no extended attributes and no timestamp preservation.
4.5.2.2 What is replicated #
| Provider type | Result in the create stratum |
|---|---|
| Regular file | A regular file with the same contents and mode |
| Symbolic link | A symbolic link with the same target |
| Directory | An empty directory with the same mode; contents are not copied |
A device node, FIFO, or socket is never copied up. Anything that is not
a regular file, directory or symlink is refused with EROFS before a
copy-up begins.
The security descriptor is carried by KACS rather than replicated as an
attribute (§4.6.3). Every other extended attribute is copied, with
three exclusions: the canonical descriptor attribute, anything in the
system.stratafs. namespace, and the staging marker attribute. Each
is copied with XATTR_CREATE, and security.capability goes through a
dedicated KACS entry point rather than a raw write.
No attribute is silently discarded. Any per-attribute failure aborts
the copy-up, as does a listing that fails or exceeds XATTR_LIST_MAX;
in every case the error reported is EIO, whatever the underlying one
was.
Modification timestamps are preserved for regular files and for
directories, and not for symbolic links, which receive the current
time. That is a divergence from the specification's SHOULD and is
tracked as a defect. Access and change times are not preserved for any
type.
Ownership is not preserved. The staged object is created with the calling task's credentials, and the metadata copy transfers only the mode and the modification time — there is no uid or gid transfer anywhere. The KACS security descriptor, including its owner SID, is preserved, so the descriptor-level owner is the source's; the POSIX owner is the caller's. Since disk quota keys on the POSIX owner, a copy is accounted to the caller who caused it rather than to the owner of the object it was copied from, which is the opposite of what §4.5.8 describes. This is tracked as a defect.
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 where it finds a directory empty but saw staging entries in it.
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 never be removed, and the emptiness test —
which filters staging entries — would report its parent empty and let
an rmdir proceed that then failed at the provider. Recovering on that
path costs nothing until someone actually meets the case, and reaches
directories that a recursive walk of the create stratum at mount would
have to visit every one of to find.
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.
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.5 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.
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 / Advanced Peios / 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 is a removal followed by a creation, and both halves
apply. 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.
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. And the two
halves are not atomic: the lower entry is unlinked first and the
staged file renamed into place afterwards, so a failure in between
leaves the name having lost its old provider. The caller receives the
error; the window itself is recorded only in the audit trail.
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.
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.
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.
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.
One part of the model is not implemented as specified. The right to delete an entry is checked when the delete-on-close request is armed, against the requesting token, which is correct. But it is checked against the merged parent directory rather than against the directory of the stratum where the entry actually lives, and no check is made at deletion time. The specification requires the check to name that stratum's directory specifically. This is tracked as a defect.
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 / Advanced Peios / 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.
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 staging entries, as enumeration does, so an
in-flight copy-up in the create stratum does not make rmdir fail on a
directory that looks empty through the mount.
Because it filters them, a directory holding nothing but orphaned
staging entries reports empty — and the removal would then fail at the
provider, on entries the caller cannot see and has no way to remove. So
a scan that finds the directory empty but saw staging entries runs
orphan recovery on the create stratum's copy of it before returning
(§4.5.2). Only entries belonging to no live mount are removed; one owned
by a live mount survives, and the provider's rmdir then fails
ENOTEMPTY, which is the right answer while another mount is copying up
there.
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 / Advanced Peios / 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.
| Flag | Behaviour |
|---|---|
RENAME_NOREPLACE | The destination must not be held by any participating stratum, not merely by P. Conditions 2, 5 and 6 are not evaluated, since each presupposes a destination that exists; where any stratum holds the destination the rename fails with EEXIST. |
RENAME_EXCHANGE | Both names must be provided by the same stratum, and that stratum must accept modification; otherwise EROFS. Conditions 1 and 4 apply to both names. Conditions 2, 5 and 6 are not evaluated: an exchange swaps two names that both already exist, so neither type matching nor emptiness is required of either, exactly as on any filesystem. Condition 3 is subsumed. Where either name is a directory, no stratum other than the providing one may hold entries under either name; otherwise EXDEV. |
RENAME_WHITEOUT | Fails with EINVAL, checked before everything else. stratafs has no whiteouts and cannot represent one. |
Exempting RENAME_EXCHANGE from conditions 5 and 6 is what leaves the
flag usable. Its ordinary purpose is to swap two populated directories
atomically, which condition 6 would refuse outright and condition 5
would refuse whenever the two differ in type. The stranded-entries
condition is the only destination constraint an exchange genuinely
needs, because it is the only one that arises from the names spanning
strata rather than from what the names hold.
4.5.5.2.1 The RENAME_NOREPLACE ordering #
A RENAME_NOREPLACE whose destination is held by any participating
stratum fails with EEXIST, and that EEXIST takes precedence over
conditions 1, 3 and 4. It is not stratafs that decides this. For
RENAME_NOREPLACE the VFS looks the destination up with LOOKUP_EXCL
and returns EEXIST for a positive dentry before vfs_rename runs at
all, and stratafs's merged lookup makes the dentry positive whenever any
stratum holds the name. namei.c is unpatched, so there is no point
inside ->rename from which the order could be changed.
stratafs nonetheless evaluates conditions 1, 3 and 4 first, and the code
says so. That ordering is reachable only as a race backstop — where the
destination appeared between the VFS lookup and the rename — so a caller
should not expect EROFS, EXDEV or EACCES from a RENAME_NOREPLACE
whose destination already existed.
4.5.5.3 Other behaviour #
Unknown flags bits are neither rejected nor masked; they pass through
to the VFS. Where the two provider-level names resolve to one inode the
rename is a no-op returning success. A dentry whose private state is
missing, or whose provider identity no longer matches, yields ESTALE.
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 / Advanced Peios / 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.
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 / Advanced Peios / 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.
Taking a lock does not modify an object, so it does not route (§4.5.1) and cannot itself cause a copy-up. Locking requires no write access either, so a read-only descriptor can carry an exclusive lock.
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.
4.5.8 Durability and Accounting
Peios / Advanced Peios / 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 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.
Which principal it is accounted to is not what the specification describes. Disk quota keys on the POSIX owner, and copy-up does not preserve it: the staged object is created with the calling task's credentials, and the metadata copy transfers only the mode and the modification time. A copy is therefore accounted to the caller who caused it, not to the owner of the object it was copied from.
What is preserved is the KACS security descriptor, including its owner SID (§4.6.3), so the descriptor-level owner is the source's. The two notions of owner diverge here, and only the descriptor one behaves as §4.6.3 requires. This is tracked as a defect.
No code alters ownership to redirect accounting; the divergence is one of omission. The audit record of §4.6.5 is where the causing caller is recorded.
4.6.1 Access Check Delegation
Peios / Advanced Peios / 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.
Two caching seams are worth recording precisely, because the specification requires the value evaluated to be the provider's current descriptor.
For the merged-directory checks stratafs performs itself, it is exact: the provider's attribute is re-read on every call, with no cache.
For file opens it is not. KACS caches the resolved descriptor against the outer stratafs inode, and a cache entry sourced from an attribute read is never revalidated — the only things that replace it are an explicit descriptor set on that inode and a copy-up install. A later open of the same outer inode therefore evaluates the descriptor read at the first open rather than the provider's current one. This is tracked as a defect.
The second seam is narrower. When copy-up rebinds a descriptor's inode to the copy (§4.4.3), nothing invalidates that cached descriptor, so the value retained was literally read from the old provider. No wrong decision follows, because copy-up preserves the descriptor exactly (§4.6.3) and the two are equal — but the invariant is held by that coincidence rather than by the mechanism.
4.6.2 Checks on Merged Directories
Peios / Advanced Peios / 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.
| Operation | Checked against |
|---|---|
| Resolving a name | Search |
| Enumerating | Enumerate, before the listing is captured |
| Reading an object's attributes | Search, plus whatever the object's own descriptor requires |
| Reading the origin attribute on a merged directory | Search, plus read-EA on every participant (§4.7) |
| Creating a name | Search, plus add-file or add-subdirectory on the create stratum's directory |
| Copy-up | Nothing beyond what the operation it serves already required |
| Removing a name | Search, plus delete-child on the provider's directory |
| Removing a directory | Enumerate — emptiness is judged across every participant — plus delete-child on the provider's directory |
| Rename, source | Search, plus delete-child on the source provider's directory |
| Rename, destination | Search, plus add-entry on the source provider's directory, which §4.5.5 requires to hold the destination, plus delete-child where that directory already holds the name |
| Rename of a directory | Both of the above, plus Enumerate on the object being renamed, and Enumerate on the destination where its provider is a directory |
RENAME_EXCHANGE | Search on both, plus add-entry and delete-child on the directory of the stratum providing both names |
| Link | Search on both, plus add-file on the source provider's directory |
| Creating or linking an unnamed file | Search, plus add-file on the create stratum's directory |
The mutating rights land on the directory that actually changes, which follows from §4.5: an entry appears in the create stratum's directory and disappears from the provider's, so in each case it is that directory's descriptor that decides. The two coincide whenever the create stratum is also the provider.
One right the table does not name is required anyway: the underlying
vfs_link makes KACS demand write-attributes on the source object,
which is an object-descriptor right outside the directory scope this
section covers. Linking an unnamed file is exempt, since stratafs marks
the source for that purpose.
The effective rights on a merged directory are therefore the intersection of the rights on its participants, with mutating rights additionally required on the directory that is actually modified. Requiring the intersection is fail-closed and admits no partial results: the alternative would let the names held by a restrictively protected directory be enumerated by a caller who could not enumerate it directly, because a permissive directory of higher precedence happened to provide the merged path.
One consequence is worth stating plainly. Because creation is governed by the create stratum's directory descriptor, and a created name shadows the same name in every lower stratum, the right to create in the create stratum's directory is the right to determine what every lower stratum's entry of that name resolves to.
4.6.2.1 Where the create stratum's directory does not exist #
A merged directory has a create stratum whether or not that subdirectory exists (§4.3.2), so a check naming "the create stratum's directory" has to be evaluated against a directory that is not there yet.
Every such check is performed before any part of the operation is carried out, and in particular before any directory is materialised: the authorisation call strictly precedes parent materialisation in creation, in tmpfile creation, and in the unnamed-link path. Where the checks fail, nothing has been created, so the create stratum is left exactly as it was.
Where the create stratum does not hold the path, the check falls back
to the corresponding provider directory — the merged provider of
that same relative path — which is the descriptor the directory will
carry once materialised (§4.6.3). It is never skipped, and never
substituted with an ancestor's descriptor. Where no provider exists
either, the result is EROFS.
Materialising the intervening directories is part of the operation, not a separate one. No per-ancestor authorisation is taken; the mkdirs are exempted by the copy-up context. Checking against the descriptor the directory will have keeps the answer independent of how much of the create stratum happens to have been materialised already: the first caller to write into a deep path and the hundredth face the same check, against the same descriptor.
A create that fails after materialisation for a reason other than a
check — EEXIST, ENOSPC — does leave the intervening directories in
place.
4.6.2.2 Copy-up carries no separate authority #
A copy-up requires no right beyond those the operation that provoked it already required. In particular it does not require the caller to hold the right to read the provider's object, nor the right to add an entry to the create stratum's directory. Neither copy-up path takes any authorisation at all.
Copy-up is the mechanism by which an authorised modification is realised, not an operation a caller requests. The read of the provider is stratafs's own, and the copy carries the source's descriptor unchanged (§4.6.3), so the caller obtains nothing they did not already have: the same content, under the same descriptor, at the same path.
The alternative cannot be expressed. Routing happens when a modification is performed (§4.5.1), and by then the authority that governed the open is a mask cached on the descriptor, immutable and carrying no token — so there is nothing to evaluate a fresh right against. Checking the acting token instead would break descriptor delegation, since a descriptor passed to another process would stop working there.
What remains is a resource consideration rather than an access one: a caller entitled to write a file in a stratum that will not accept modification can cause an entry to appear in the create stratum without holding rights over that directory. They gain no access by it — though they do gain the space, since §4.5.8 records that the copy is accounted to them rather than to the preserved owner.
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.
One adjacent path does borrow one. The stale-staging recovery scan opens the create-stratum directory with the mounter's credentials rather than the caller's, and since the KACS token derives from the current credentials, that read is authorised as the mounter. It runs inside a copy-up context in any case, so it changes no decision.
4.6.3 Descriptors on Copy-Up
Peios / Advanced Peios / 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.
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. 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 a different matter, and is not preserved — §4.5.8 records what follows.
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 / Advanced Peios / 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.
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.1 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.
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.2 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 / Advanced Peios / 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 record, successful or not. The payload is a map of six keys:
| Key | |
|---|---|
path | The relative path within the mount, /-prefixed |
provider_index | The stratum the object was copied from |
provider_stratum | That stratum's path |
create_index | The stratum it was copied into |
create_stratum | That stratum's path |
result_errno | Zero on success, the failure otherwise |
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 keys.
The ENOTDIR of parent materialisation is reported through this event
with its result_errno rather than through the refusal event below;
the required fields are all present, under a different name.
4.6.5.2 Refused mutation #
A mutation refused because of how the mount is arranged emits a record with six keys: the path, the operation name, the provider index, the provider stratum path, the errno, and whether the refusal was deferred. The provider stratum is a string where a provider is known and msgpack nil where none is; 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. It reports a provider
index of -1 and a provider_stratum of msgpack nil, so a reader
can tell "no provider was involved" from "the provider's path is empty".
The two fields agree: an index of -1 always accompanies a nil stratum.
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.
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 / Advanced Peios / 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 / Advanced Peios / 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 |
strata= supplied on a remount | 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 / Advanced Peios / 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_KUNIT_TEST builds the in-kernel unit tests.
The translation units are:
super.olookup.oinode.ofile.odir.oxattr.ocopy_up.o
5.1 Overview
Peios / Advanced Peios / 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 / Advanced Peios / 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.
| Field | Description |
|---|---|
| Name | The hive name, e.g. Machine, Users. Case-preserving, compared case-insensitively. |
| Root GUID | The GUID of the hive's root key, supplied by the source at registration. |
| Source | The source slot backing this hive. |
| Status | Active or Unavailable, tracking source connectivity. |
Status is a property of the source slot rather than of the individual hive, which amounts to the same thing: a slot's hives are exactly one source's, and they go Down together.
5.2.1.1 Route identity #
A hive route is identified by the pair (case-folded name, scope), where the scope is either the global namespace or one private scope GUID. That pair must be unique across every registered source; a source claiming one another source already holds is rejected.
The same name may appear in different scopes, which is precisely how a private hive shadows a global one without colliding with it.
A hive is backed by exactly one source. A source may back many.
5.2.1.2 Routing a path #
LCS takes the first component of a path — everything before the first
separator — and looks it up. Registered and active routes to the
backing source. Not registered is ENOENT. Registered but Down is
EIO.
Before any source registers, the table is empty and every path yields
ENOENT (§5.10.1).
5.2.1.3 CurrentUser is not a hive #
CurrentUser\ is a kernel-level alias. When a caller-supplied absolute
path starts with it, LCS reads the user SID from the calling thread's
effective token and rewrites the path to Users\<SID>\... before
routing. The rewritten path is re-checked against the total-length
limit, since a textual SID is longer than the alias it replaced.
Three constraints keep this safe.
It applies only to the first component of a path, and only to a
caller-supplied absolute one. It does not apply to symlink targets:
a target beginning with CurrentUser\ is followed literally, routes as
a hive of that name, and finds nothing. Without that rule, a symlink
containing CurrentUser\ would redirect a privileged service into the
service's own user hive — a confused deputy.
And CurrentUser cannot be registered as a hive name. LCS rejects the
registration with EINVAL, so the alias can never collide with a real
hive.
Symlink targets are subject to ordinary hive routing, private hives included. A sandboxed process resolving a symlink sees the same registry through it that it sees directly, which is the point of sandboxing it.
5.2.1.4 Two names the kernel knows #
LCS is source-agnostic about routing but not entirely ignorant of
names. Users is compiled in as the target of CurrentUser\
rewriting. Machine is matched, case-insensitively and only for a
global hive, to decide when to run the bootstrap refresh that reads
LCS's own configuration (§5.10.2).
Neither is a routing decision — an unregistered Machine routes
nowhere like any other name — but the two names do exist in the kernel.
In practice loregd registers Machine and Users at boot. Other
sources may register other hives at any time.
5.2.1.5 Names #
Hive names follow the same rules as key name components: no backslash,
no forward slash, no null byte, valid UTF-8, non-empty, and no longer
than MaxPathComponentLength. They are case-preserving and compared
case-insensitively (§5.2.8).
5.2.2 Private Hives
Peios / Advanced Peios / 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. 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, rejects nil and duplicate GUIDs, and 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:
- A hive's root GUID may not be nil, and 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, and a hive with it 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 returnedENOENT. - 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 / Advanced Peios / 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).
| Field | Mutable | Layered | Description |
|---|---|---|---|
| GUID | No | No | Identity, assigned by LCS at creation, persisted by the source. |
| Name | No | No | The key's own name component. Informational; the authoritative name is in the path entry. |
| Parent GUID | No | No | The parent key. Nil for a hive root. |
| Security Descriptor | Yes | No | Computed from the parent at creation; changed at runtime with WRITE_DAC / WRITE_OWNER. |
| Last write time | Yes | No | Updated when a value is written or deleted or the descriptor changes. |
| Volatile | No | No | The source stores this key in non-persistent storage only. |
| Symlink | No | No | This key is a symbolic link (§5.2.4). |
No key property is layer-qualified. Properties belong to the object. Changing a key's structural type through a layer is not a matter of setting a flag; the layer hides the original key and creates a new one at the same path, which is the ordinary overlay pattern.
5.2.3.1 Identity #
A key's identity is its GUID, not its path. Two keys occupying the same path at different times are different objects with different GUIDs. A path is a name that maps to an identity; the identity outlives the name.
GUIDs are assigned by LCS — never by a source — and pushed to the source at creation, where they become the primary key of its storage. Once a path has been resolved to a GUID at open time, every subsequent RSI operation uses the GUID directly.
The generator is the kernel's UUIDv4: random bytes from the kernel CSPRNG with the RFC 4122 version and variant bits set. 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 / Advanced Peios / 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. There is 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.
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.
Two paths in the walk use the compiled-in default rather than the
configured value, so a SymlinkDepthLimit other than 16 is not honoured
everywhere.
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. Failing it is EPERM.
5.2.5 Path Entries
Peios / Advanced Peios / 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.
| Field | Description |
|---|---|
| Parent GUID | The parent key. |
| Child name | The key's name under that parent — one component, not a path. |
| Layer | The layer this entry belongs to. |
| Target | The GUID of the key at this path in this layer, or HIDDEN. |
| Sequence | A monotonic number assigned by LCS at creation, used for tiebreaking within a precedence tier. |
On the wire a HIDDEN entry is a target type of 1 with an all-zero GUID (§5.A). A source returning a non-zero GUID on a HIDDEN entry is returning malformed data.
5.2.5.1 Creating a key #
Creating a key in a layer always assigns a fresh GUID and produces two
records: a path entry (parent, name, layer) → GUID with a new
sequence number, and a key record carrying that GUID.
LCS sends RSI_CREATE_ENTRY first and RSI_CREATE_KEY second. That
order matters for the race: if another caller got there first, the
entry creation returns RSI_ALREADY_EXISTS and LCS retries the whole
thing as an open, reporting REG_OPENED_EXISTING. Failure on the
second call, for a GUID LCS just minted, is not a race and fails
closed (§5.2.3).
If a different layer already has a key at that path, the new layer gets its own distinct GUID. Each layer has its own key object, and resolution decides which is visible.
No operation creates a path entry pointing at an existing GUID.
reg_create_key always mints a new one. The namespace is a tree, never
a graph.
5.2.5.2 No GUID sharing across layers #
The path table's shape would technically permit two entries referencing one GUID, which would be a hard link. No API exposes it. Every key has exactly one canonical parent and name, and LCS validates that a source is not reporting otherwise.
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 / Advanced Peios / PKM / LCS / The Data Model
A value is a named, typed datum inside a key. Values hold the configuration data; keys hold values.
| Field | Description |
|---|---|
| Key GUID | The key this value belongs to. |
| Name | The value's name; the empty string is the default value. Case-preserving, compared case-insensitively. |
| Type | A registry value type. |
| Data | An opaque byte array, up to MaxValueSize (default 1 MB). |
| Layer | The layer this entry belongs to. Every write is tagged. |
| Sequence | Assigned by LCS at write time, for tiebreaking within a precedence tier. |
A key holds many values with distinct names, and at most one unnamed one.
5.2.6.1 One name, many entries #
A single (key GUID, value name) pair can have several entries in the
source — one per layer that has written to it. The source stores them
all and returns them all; LCS resolves the effective value at read
time (§5.3.6).
That is the mechanism that makes layer deletion revert configuration automatically. Delete the layer, its entries go, and the next-highest-precedence entry becomes effective. Nothing has to be recomputed or rewritten.
5.2.6.2 Types #
The full Windows type set is supported, for registry.pol fidelity:
REG_NONE, REG_SZ, REG_EXPAND_SZ, REG_BINARY, REG_DWORD,
REG_DWORD_BIG_ENDIAN, REG_LINK, REG_MULTI_SZ,
REG_RESOURCE_LIST, REG_FULL_RESOURCE_DESCRIPTOR,
REG_RESOURCE_REQUIREMENTS_LIST and REG_QWORD, numbered 0 to 11.
Values are in §5.A.
LCS stores the type tag and returns it on read but does not interpret
the data. The single exception is REG_LINK read as a symlink key's
default value, which triggers target resolution (§5.2.4).
The three hardware-resource types, 8 to 10, describe device resource
assignments in the Windows HKLM\HARDWARE hive, which the Windows
kernel rebuilds at every boot. Peios has no equivalent: hardware
enumeration belongs to the Linux device model, sysfs and /proc/iomem,
not to the registry. LCS accepts these tags only so a value carrying
one round-trips without loss. They have no semantics, LCS never
produces one, and for every operation they behave as REG_BINARY.
Rejecting a faithfully-copied value would break the fidelity guarantee
that is the reason the registry exists.
Type tags are validated at every write boundary, before sequence
allocation, transaction enlistment or source dispatch. An unknown code
is EINVAL.
5.2.6.3 REG_TOMBSTONE #
One further type, REG_TOMBSTONE (0xFFFF), is internal. It is never
returned to a caller reading a value — a caller whose effective entry
is a tombstone gets ENOENT, which is exactly what a tombstone means.
There is no separate tombstone flag in the REG_IOC_SET_VALUE
argument. Writing type REG_TOMBSTONE is the explicit tombstone
operation, and 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.7 Tombstones
Peios / Advanced Peios / 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.7).
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 / Advanced Peios / 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 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.
Two comparisons in the kernel are ASCII-only rather than folded: the
check for whether a layer name is base on two of its call sites, and
KACS's duplicate check when parsing private layer names into a token.
For the literal string base the two agree; for arbitrary names they
do not.
5.2.9 Deletion and Orphans
Peios / Advanced Peios / 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 to purge records with no path entries before it becomes Active (§5.8.2).
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 / Advanced Peios / 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.
| Field | Mutable | Description |
|---|---|---|
| Name | No | The layer's identity — not a GUID, not an integer. Case-preserving, compared with Unicode Simple Case Folding. Bounded by MaxPathComponentLength. |
| Precedence | Yes | Higher wins. Default 0. |
| Enabled | Yes | A disabled layer is invisible during resolution unless it is attached to the resolving thread's credentials (§5.3.5). Default true. |
| Owner | — | The SID of the principal that created the layer. Informational only. |
The name being the identity is deliberate. Layer names are
code-generated and meaningful by construction —
role-jellyfin, gpo-security-baseline — so there is nothing an
opaque identifier would add.
Owner is never used for an access check; authorisation is the
descriptor on the layer's metadata key (§5.3.4). It is also not quite
immutable as a field: a refresh re-reads the Owner value and
re-selects it, so rewriting the value does change what is cached.
Because nothing consults it, that has no effect on anything.
5.3.1.1 Precedence tiers #
The base layer and role layers all sit at precedence 0. Within one tier, the most recent write wins — the highest sequence number. Group Policy layers sit above 0 and override both.
Establishing or raising a layer's precedence above 0 requires
SeTcbPrivilege (§5.3.4). That is what keeps the tier boundary
meaningful.
5.3.1.2 Caps #
MaxTotalLayers, default 1024, bounds the in-memory layer table.
Creating a layer when it is full returns ENOSPC.
The table itself is a fixed array sized at compile time for 1023
dynamic layers plus the base layer. MaxTotalLayers is configurable up
to 65536, and a value above 1024 validates and publishes, but the table
still runs out at 1023 dynamic entries. Values below 1024 bind
correctly.
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 / Advanced Peios / 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, 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, and it usually does,
but 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 and for system initialisation.
Before the base layer's metadata key exists — first boot, before seed
restore — LCS uses a compiled-in default descriptor granting SYSTEM and
Administrators KEY_ALL_ACCESS, so writes into the base layer are
possible from the very beginning. The compiled-in default is replaced by
the real descriptor as soon as seed restore creates the key.
5.3.2.3 base is matched two ways #
The check for whether a name is the base layer is implemented twice in
the kernel: once using Unicode Simple Case Folding like every other
name comparison, and once using ASCII case-insensitive comparison, on
two of its call sites. For the literal string base the two agree.
5.3.3 Layer Metadata
Peios / Advanced Peios / 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 |
A value of the wrong type, or a REG_DWORD that is not exactly four
bytes, or an Enabled greater than 1, is malformed metadata and is
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. 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 / Advanced Peios / 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 inherits from the
Machine hive root — SYSTEM and Administrators with KEY_ALL_ACCESS.
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 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 #
On first boot, before seed restore, Layers\base\ does not exist. LCS
falls back to a compiled-in default descriptor granting
KEY_ALL_ACCESS to SYSTEM and Administrators, so base-layer writes
work from the start. It is replaced by the persisted descriptor the
moment seed restore creates the key.
5.3.4.2 Layer lifecycle #
| Operation | Requirement |
|---|---|
| Create a layer at precedence 0 | KEY_CREATE_SUB_KEY on Layers\ |
| Create a layer above precedence 0 | KEY_CREATE_SUB_KEY on Layers\ and SeTcbPrivilege |
| Write into a layer | KEY_SET_VALUE on the layer's metadata key |
| Modify layer metadata | KEY_SET_VALUE on the metadata key; raising precedence above 0 additionally requires SeTcbPrivilege |
| Delete a layer | DELETE on the metadata key's fd |
Everything except the precedence rule is controlled purely by the descriptor on the metadata key.
5.3.4.3 The precedence gate #
SeTcbPrivilege is required specifically to establish or raise a
layer's precedence above 0. It is defence in depth: compromising the
descriptor on Layers\ is not enough to create a Group Policy-tier
layer.
The check is synchronous and inline at REG_IOC_SET_VALUE time, and it
happens early — before sequence allocation, before transaction
enlistment, before the source is contacted. It runs when three things
hold: the target key GUID is in the set of known layer metadata keys,
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. 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 / Advanced Peios / 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.
The activity test is exactly: 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, and 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 also deduplicates private layer names using ASCII case-insensitive comparison, where LCS matches them with Unicode Simple Case Folding. Two names that LCS would treat as one layer can both sit on a token.
5.3.5.4 The privilege that is not checked #
Attaching a private layer whose precedence is above 0 ought to require
SeTcbPrivilege — otherwise an unprivileged process can attach an
existing high-precedence disabled layer to its own credentials and see,
and potentially influence, Group Policy-tier configuration.
That check does not exist. KACS never consults the LCS layer table
when parsing the credential extension, and there is no precedence
lookup and no privilege test anywhere on the attachment path. What does
gate attachment is SeCreateTokenPrivilege, because private layer
names can only enter a token when the token is created — the same
blanket gate that governs scope GUIDs.
5.3.6 Resolution
Peios / Advanced Peios / 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_VALIDATION_FAILURE audit event
(§5.4.4).
5.3.7 The Sequence Counter
Peios / Advanced Peios / 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 / Advanced Peios / 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. -
The granted mask is stored on the fd. It never changes.
-
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 / Advanced Peios / 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_VALIDATION_FAILURE 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 #
| Operation | Required |
|---|---|
REG_IOC_QUERY_VALUE, QUERY_VALUES_BATCH, ENUM_VALUES | KEY_QUERY_VALUE |
REG_IOC_SET_VALUE, DELETE_VALUE, BLANKET_TOMBSTONE, FLUSH | KEY_SET_VALUE |
REG_IOC_ENUM_SUBKEYS | KEY_ENUMERATE_SUB_KEYS |
REG_IOC_QUERY_KEY_INFO | READ_CONTROL |
REG_IOC_DELETE_KEY, HIDE_KEY | DELETE |
REG_IOC_NOTIFY | KEY_NOTIFY |
REG_IOC_GET_SECURITY | READ_CONTROL for owner, group and DACL; ACCESS_SYSTEM_SECURITY for the SACL |
REG_IOC_SET_SECURITY | WRITE_OWNER for owner or group; WRITE_DAC for the DACL; ACCESS_SYSTEM_SECURITY for the SACL |
REG_IOC_BACKUP | SeBackupPrivilege, no per-key check |
REG_IOC_RESTORE | SeRestorePrivilege, no per-key check |
| creating a key | KEY_CREATE_SUB_KEY on the parent |
| creating a symlink key | KEY_CREATE_SUB_KEY and KEY_CREATE_LINK on the parent, plus SeTcbPrivilege or Administrators |
Where a REG_IOC_SET_SECURITY or GET_SECURITY request names several
components, every right those components imply must be present before
the source is contacted.
REG_IOC_FLUSH requires KEY_SET_VALUE because a flush is only
meaningful to a caller that wrote something and wants it durable.
Requiring a write right stops an unprivileged reader using flush as a
disk-I/O amplifier.
5.4.2.5 Enumeration exposes names, not contents #
REG_IOC_ENUM_SUBKEYS performs no per-child access check. Every
visible child is returned, whatever the caller's access to it. The
caller learns names, and must open each child separately — with a real
AccessCheck — to read anything.
Subtree watches work the same way: a watcher is told a descendant was
created without a check on that descendant. Structure visibility is
deliberately weaker than content visibility, matching
RegNotifyChangeKeyValue and RegEnumKeyEx.
5.4.3 Inheritance and Hive Roots
Peios / Advanced Peios / 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. It is only cleared on the child copy
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.2 Hive roots #
A hive root has no parent, so it is the top of every inheritance chain below it and cannot inherit anything itself. Its descriptor is created by the source, on first boot, and LCS enforces whatever the source stored.
LCS holds no template. There are no hardcoded SIDs, no default hive root descriptors, and no code that would construct one — searching for them finds nothing. The defaults that follow are what loregd writes; they are conventions of the source, not properties of the kernel.
Machine\:
| Principal | Rights | Inheritance |
|---|---|---|
| SYSTEM | KEY_ALL_ACCESS | Container-inherit |
| Administrators | KEY_ALL_ACCESS | Container-inherit |
| Authenticated Users | KEY_READ | Container-inherit |
Users\<SID>\:
| 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. 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 or the DACL needs READ_CONTROL; reading the SACL needs
ACCESS_SYSTEM_SECURITY. Setting owner or group needs
WRITE_OWNER; 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. The result must still have an owner; a
merge that would leave the descriptor ownerless is EINVAL. A null
group SID stays valid.
Enlisting a descriptor change in a transaction gives it atomicity with the rest of the transaction's operations. It does not make it layer-qualified: the change is still a direct mutation on the key, is still not reverted by deleting a layer, and is simply not applied at all if the transaction aborts.
5.4.4 Audit
Peios / Advanced Peios / PKM / LCS / Security
LCS emits audit events through KMES. Seven events exist.
| Event | Emitted when |
|---|---|
LCS_KEY_OPEN_AUDIT | A key open matched a SACL audit ACE. |
LCS_BACKUP_START | Before REG_IOC_BACKUP reads any subtree data. |
LCS_BACKUP_COMPLETE | After a backup completes or fails after starting. |
LCS_RESTORE_START | Before REG_IOC_RESTORE modifies any source state. |
LCS_RESTORE_COMPLETE | After a restore completes or fails after starting. |
LCS_SOURCE_VALIDATION_FAILURE | LCS rejected malformed source data. |
LCS_SELF_CONFIG_INVALID | LCS rejected an invalid self-configuration value. |
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. GUIDs are 16-byte binary values; SIDs are binary KACS encodings.
5.4.4.1 The caller summary #
Six of the seven carry a caller submap describing the effective
token used for the operation. It has nine fields and no more:
effective_token_guid, true_token_guid, process_guid and
user_sid, then authentication_id, token_id, token_type,
impersonation_level and integrity_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. A primary token reports an impersonation level of 0.
5.4.4.2 Key opens #
LCS_KEY_OPEN_AUDIT carries the caller summary, the key GUID, the
requested and granted access masks, the decision (allowed or
denied) and sacl_match_flags — bit 0 for a success-audit match, bit
1 for a failure-audit match, no other bits.
granted_access 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. requested_access 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 Source validation failures #
LCS_SOURCE_VALIDATION_FAILURE carries the source slot identifier and
then, where each is known, the hive name, the RSI request id, the
operation code and the key GUID. The last field, validation_class,
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.4 Configuration #
LCS_SELF_CONFIG_INVALID carries the parent path and value name of
the offending parameter, the expected type and numeric range, what was
actually received — one of missing, wrong_type or
dword_out_of_range, with the actual type or value where applicable —
and 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.5 What happens when emission fails #
The policy differs per event, and the differences are the point.
LCS_KEY_OPEN_AUDIT. 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_BACKUP_START,LCS_RESTORE_START. Emission failure returnsEIOand the operation does not start. Nothing is read and nothing is written.LCS_BACKUP_COMPLETE,LCS_RESTORE_COMPLETE. The operation has already finished. Emission is attempted; failure does not change the result.LCS_SOURCE_VALIDATION_FAILURE. The triggering operation is already failing withEIO. Emission is attempted; failure does not change that.LCS_SELF_CONFIG_INVALID. 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 rule underneath all five: 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 / Advanced Peios / 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 / Advanced Peios / 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.
| Parameter | Description |
|---|---|
parent_fd | An open key fd to resolve relative to, or -1 for an absolute path. No AccessCheck is performed on the parent. |
path | A null-terminated registry path — absolute with a hive prefix when parent_fd is -1, relative to the parent key otherwise. |
desired_access | Requested rights, raw generic bits, MAXIMUM_ALLOWED, or a combination (§5.4.2). Zero and unknown bits are EINVAL. |
flags | REG_OPEN_LINK (0x01) opens a symlink key rather than following it. Every other bit is reserved and must be zero. |
The open proceeds as follows.
- 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.
| Errno | Condition |
|---|---|
ENOENT | The key does not exist after layer resolution. |
EACCES | AccessCheck did not grant everything requested. |
EINVAL | Malformed path; zero or unknown desired_access bits; a symlink whose effective default value is not REG_LINK; maximum depth exceeded. |
ELOOP | Symlink depth limit exceeded. |
ENAMETOOLONG | A component or the total path is too long. |
ETIMEDOUT | The source did not answer within RequestTimeoutMs. |
EIO | The source failed or is unavailable. |
ENOMEM | Kernel allocation failure. |
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.
| Field | Description |
|---|---|
parent_fd | As reg_open_key. |
path_ptr | Pointer to a null-terminated path. |
desired_access | As reg_open_key. |
flags | REG_OPTION_VOLATILE (0x01), REG_OPTION_CREATE_LINK (0x02). Other bits reserved. |
layer_ptr | Pointer to a null-terminated layer name for creation, or null for the base layer. Ignored if the key already exists. |
txn_fd | A transaction fd, or -1. A non-negative value makes creation a mutating operation that binds or reuses that transaction. |
disposition_ptr | Receives REG_CREATED_NEW (1) or REG_OPENED_EXISTING (2). May be null. |
_pad0, _pad1 | Reserved; must be zero. |
If the key exists, this behaves as reg_open_key, the layer
parameter is ignored, and the disposition is REG_OPENED_EXISTING.
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).
| Errno | Condition, in addition to reg_open_key's |
|---|---|
ENOENT | The parent does not exist, or the named layer is not in the layer table. |
EACCES | The parent denied KEY_CREATE_SUB_KEY, the inherited descriptor denied the requested access, or layer write authorization failed. |
EPERM | REG_OPTION_CREATE_LINK without KEY_CREATE_LINK on the parent, or without SeTcbPrivilege or Administrators. |
ENOSPC | The per-value layer cap was exceeded. |
EINVAL | A non-volatile key under a volatile parent; a non-zero reserved field. |
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 / Advanced Peios / 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 #
| Errno | Condition |
|---|---|
EACCES | The granted mask lacks the required right, or layer write authorization failed. |
EFAULT | An input pointer is invalid, or a non-zero-length output buffer pointer is null or unwritable. |
EIO | The source is unavailable, failed, or the transaction's source went Down. |
ETIMEDOUT | The source did not answer within RequestTimeoutMs, or the transaction had timed out. |
ENOMEM | Kernel allocation failure. |
EXDEV | The transaction is bound to a different hive. |
ENOENT | The named layer is not in the layer table. |
EINVAL | A non-zero reserved or padding field. |
ENOTTY | An ioctl number this fd type does not implement. |
5.5.3.2 Reading #
REG_IOC_QUERY_VALUE returns the effective value at a name:
its type, data, the sequence number of the winning entry, and the
canonical name of the winning layer — which is the layer table's
spelling, not whatever string the source stored. A base-layer entry
therefore reports base. A winning tombstone or blanket, or no
entries at all, is ENOENT.
REG_IOC_QUERY_VALUES_BATCH returns every effective value on the
key in one call: name, type and data for each, with tombstoned and
blanket-masked values omitted. This is the call to use when you want
them all (§5.3.6).
REG_IOC_ENUM_VALUES and REG_IOC_ENUM_SUBKEYS return the
entry at an index, and ENOENT past the end. Both re-resolve the full
set on every call, and enumeration order is undefined — see §5.3.6 for
why the batch call usually wins.
ENUM_SUBKEYS performs no per-child access check. It returns every
visible child with its last write time, subkey count and value count.
The caller learns names and must open each child separately to read
anything (§5.4.2).
REG_IOC_QUERY_KEY_INFO returns the key's name, last write time,
subkey and value counts, the maximum subkey name length, maximum value
name length and maximum value data size, the descriptor size, the
volatile and symlink flags, and the hive generation number. It requires
READ_CONTROL.
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. There is no
kernel-side query-then-write: the check is the source's, and it is
atomic there or nowhere.
The condition is evaluated against the layer's own entry, not against the effective value. A higher-precedence layer overriding a value is not a lost update; it is the layer system working.
Additional errors: EINVAL for an unknown type or a tombstone with
data; EAGAIN for a failed conditional write; ENOSPC for the layer
cap or oversized data; ENAMETOOLONG for the value name; EPERM for a
Precedence above 0 without SeTcbPrivilege (§5.3.4).
REG_IOC_DELETE_VALUE removes one layer's entry at a value name —
whether that entry was a value or a tombstone. Removing a layer's
opinion lets lower-precedence layers surface.
The operation is meant to be idempotent, and it is idempotent as far as
a caller sees, provided the source answers RSI_OK for an entry that
was not there. LCS does not mask a source's RSI_NOT_FOUND; that
propagates as ENOENT. Idempotency is a source obligation, not a
kernel behaviour.
REG_IOC_BLANKET_TOMBSTONE sets or removes a blanket tombstone for
a layer on this key (§5.2.7). A new sequence number is assigned for
dispatch ordering, and watch events are generated for every value whose
effective state changed.
REG_IOC_DELETE_KEY removes this key's path entry from a layer.
The parent GUID comes from the fd's ancestor chain and the child name
from the last component of its resolved path. ENOTEMPTY if the key
has visible children; EINVAL on a hive root (§5.2.9).
REG_IOC_HIDE_KEY creates a HIDDEN path entry at the same place
instead, masking lower-precedence entries. The caller must hold an open
fd to the key, which is how it proved access to it. EINVAL on a hive
root.
5.5.3.4 Security #
REG_IOC_GET_SECURITY and REG_IOC_SET_SECURITY read and
merge Security Descriptor components, selected by a security_info
bitmask. §5.4.3 covers the rights, the merge, and the validation.
5.5.3.5 Watches, durability, bulk #
REG_IOC_NOTIFY arms, re-arms or disarms a watch (§5.6.1).
REG_IOC_FLUSH tells the source to persist pending writes for this
key's hive, and returns when persistence is confirmed. The hive name
comes from the first component of the fd's resolved path. It requires
KEY_SET_VALUE (§5.4.2).
REG_IOC_BACKUP and REG_IOC_RESTORE export and replace a
subtree. They are privilege-gated with no per-key access check, and
they are covered in §5.9.
5.5.4 Transaction Ioctls
Peios / Advanced Peios / 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:
| Errno | Condition |
|---|---|
| — | Success. The object becomes COMMITTED, poll waiters are woken, watch events are delivered, and further use of the fd returns EINVAL. |
EINVAL | Already committed, or never bound to a source. |
EBUSY | The source could not take the write lock. The transaction stays ACTIVE_BOUND; retry. |
EIO | The source failed to commit. The transaction stays ACTIVE_BOUND. |
ETIMEDOUT | The transaction timed out before the commit completed. |
EBUSY and EIO both leave the transaction usable: the mutation log
is retained, no events are emitted, and poll waiters are not woken as
though it had become terminal. The caller retries or closes the fd to
abort.
5.5.4.2 REG_IOC_TXN_STATUS #
Reports the transaction's state and a terminal errno into a
reg_txn_status_args. It reads nothing from the caller, consistent
with its _IOR direction, and can fail only with EFAULT on an
unwritable output pointer.
| 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 / Advanced Peios / 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 / Advanced Peios / 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 layer whose value was on top
produces VALUE_SET for the value that surfaced underneath. Removing a
hiding entry that was concealing a lower-precedence key produces
SUBKEY_CREATED. 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.4).
5.6.1.2 Event types #
| Event | Code | Name field | Meaning |
|---|---|---|---|
REG_WATCH_VALUE_SET | 1 | value name | The effective value at this name changed or appeared. |
REG_WATCH_VALUE_DELETED | 2 | value name | The effective value at this name disappeared. |
REG_WATCH_SUBKEY_CREATED | 3 | subkey name | A child key became visible. |
REG_WATCH_SUBKEY_DELETED | 4 | subkey name | A child key became invisible. |
REG_WATCH_SD_CHANGED | 5 | empty | The watched key's Security Descriptor was modified. |
REG_WATCH_KEY_DELETED | 6 | empty | The watched key itself became invisible. |
REG_WATCH_OVERFLOW | 7 | empty | Events were dropped; re-read to recover. |
VALUE_SET fires when a value is written, when a tombstone or blanket
tombstone is removed and a lower-precedence value surfaces, and when a
layer deletion makes a different value effective. 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, as is a subtree flag other than 0 or 1 or a non-zero padding byte in the argument structure.
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 / Advanced Peios / 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 / Advanced Peios / 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, failed or timed-out transaction dispatches nothing and its log is released.
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 affected hive's generation
number and then queues a no-name OVERFLOW to every armed watch on the
affected 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 it missed anything.
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. The generation counters are maintained per
hive; the watch delivery is not.
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 / Advanced Peios / 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 / Advanced Peios / PKM / LCS / Transactions
A transaction groups registry operations so that they commit together or not at all. Installing a role writes service definitions, defaults and registry entries; a transaction is what makes that one event rather than a sequence of partially-applied ones.
reg_begin_transaction takes no arguments and returns a transaction
fd. It contacts no source: it allocates an id, creates an anonymous
inode, starts the lifetime timer, and returns. A transaction begins in
the state REG_TXN_ACTIVE_UNBOUND.
5.7.1.1 Binding #
A transaction binds to a source on its first mutating operation. There are seven: writing a value, deleting a value entry, setting or removing a blanket tombstone, creating a key, deleting a key's path entry, hiding a key, and changing a Security Descriptor.
Reads never bind. A read passed an unbound transaction fd is sent to the source with a transaction id of zero and behaves as an ordinary non-transactional read.
Once bound, every operation on that fd must target the same hive.
One that does not fails with EXDEV, and it fails in the kernel,
before anything reaches a source — a source never sees a cross-hive
operation inside a transaction. Cross-source atomicity would require
two-phase commit and is not supported.
Binding identity is the pair of source and hive root GUID carried on the transaction object, which identifies the hive exactly.
5.7.1.2 Sources that do not support transactions #
Because reg_begin_transaction chooses no source, it cannot fail for
lack of transaction support. The failure surfaces on the operation that
would have bound: LCS sends RSI_BEGIN_TRANSACTION, the source answers
RSI_TXN_NOT_SUPPORTED, and the operation returns ENOTSUP. The
transaction stays ACTIVE_UNBOUND, its source binding untouched, and
the caller can use the fd against a different hive.
There is no advance check of whether a source supports transactions. The answer comes from the source, on the attempt.
If the source is Down before the first bind, the binding operation
fails EIO under the ordinary rules and the transaction likewise
remains unbound. If a source goes Down after binding, the transaction
becomes REG_TXN_SOURCE_DOWN (§5.8.5).
5.7.1.3 States #
| 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. 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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, and the root GUIDs within one request are distinct from each other.
- A hive without
RSI_HIVE_PRIVATEcarries no scope GUID, and no unknown flag bits are set. - The hive count is non-zero and within
MaxHivesPerSource(64), and the source count is withinMaxRegisteredSources(32). Either isENOSPC. - 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 the already-registered state is checked for consistency, but an incoming request's root GUIDs are not compared against those of existing slots.
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, 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 / Advanced Peios / 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. 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.4 Validation
Peios / Advanced Peios / 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_VALIDATION_FAILUREaudit 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 / Advanced Peios / 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.
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 / Advanced Peios / 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 / Advanced Peios / PKM / LCS / Backup and Restore
REG_IOC_BACKUP exports the key on the fd and its entire subtree to
another fd.
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_BACKUP_START is emitted before any subtree data is read, and
if it cannot be emitted the backup returns EIO and does not start.
LCS_BACKUP_COMPLETE 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.
5.9.3 Restore
Peios / Advanced Peios / 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, and 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, and so is a non-root GUID equal to the target root's.
A non-root GUID that already exists outside the subtree being
replaced is EEXIST — but it is discovered by the source rejecting the
create during replay, not by a check beforehand. LCS has no index of
which GUIDs exist elsewhere, so the collision surfaces mid-restore, and
the transaction rolls back.
The parent of every path entry, after remapping, must be either the restore root or a key record already processed earlier in the stream. That check is made up front, and it is what stops a crafted backup injecting path entries into arbitrary parts of the existing namespace outside the subtree being replaced.
5.9.3.8 Watches #
A restore is an arbitrary subtree replacement and LCS retains no exact
before-and-after diff for it. On a successful commit it publishes the
affected hive's generation increment and dispatches a no-name
OVERFLOW to the armed watches on that source (§5.6.3). A restore that
fails or aborts before commit emits nothing.
5.10.1 The Bootstrap Problem
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.
| Value | Default | Range | Bounds |
|---|---|---|---|
RequestTimeoutMs | 30000 | 1000–600000 | A source round trip (§5.8.3). |
TransactionTimeoutMs | 30000 | 1000–600000 | The lifetime of an open transaction (§5.7.1). |
NotificationQueueSize | 256 | 16–65536 | Queued events per watcher before overflow (§5.6.4). |
SymlinkDepthLimit | 16 | 1–64 | Symlink resolution depth (§5.2.4). |
MaxValueSize | 1048576 | 4096–67108864 | One value's data, in bytes. |
MaxKeyDepth | 512 | 32–4096 | Key hierarchy nesting. |
MaxPathComponentLength | 255 | 64–1024 | One key, value or layer name, in UTF-8 bytes. |
MaxTotalPathLength | 16383 | 1024–65535 | A whole path, in UTF-8 bytes. |
MaxLayersPerValue | 128 | 1–1024 | Layers writing to one (key, value name) (§5.3.1). |
MaxBoundTransactionsPerSource | 16 | 1–256 | Concurrently bound transactions per source (§5.7.1). |
MaxReadOnlyTransactionsPerSource | 16 | 1–256 | Concurrent backup snapshots per source (§5.9.2). |
MaxTotalLayers | 1024 | 16–65536 | Distinct layers in the in-memory table (§5.3.1). |
MaxRegisteredSources | 32 | 1–256 | Concurrently registered sources. |
MaxHivesPerSource | 64 | 1–1024 | Hives one source may register. |
MaxConcurrentRSIRequests | 256 | 8–4096 | In-flight RSI requests per source (§5.8.3). |
MaxScopeGUIDsPerToken | 8 | 1–256 | Private hive scope GUIDs on a token (§5.2.2). |
MaxPrivateLayersPerToken | 16 | 1–256 | Private layer names on a token (§5.3.5). |
MaxSubtreeWatchDepth | 0 | 0–4096 | Subtree watch depth; 0 is unlimited (§5.6.3). |
MaxTransactionWatchEventBurst | 4096 | 256–65536 | Watch events per watcher from one commit (§5.6.3). |
Unknown values under this key are ignored. There are exactly nineteen
parameters and no undocumented ones; a separate set under
Machine\System\KMES\ belongs to KMES.
Two limits are not among them. The transaction mutation log is capped at 4096 entries by a compile-time constant (§5.7.2), and hard ceilings on total path length and key depth exist independently of configuration — set equal to the range maxima above, so they never conflict.
5.10.3.1 Where a configured value does not fully bind #
Three of the nineteen do not do everything their range suggests.
MaxTotalLayers may be configured up to 65536, but the in-memory layer
table is a fixed array sized at compile time for 1023 dynamic layers
plus the base layer. A value above 1024 validates and publishes, and
then layer creation fails ENOSPC at 1023 regardless. Values below
1024 bind correctly.
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.
SymlinkDepthLimit is honoured on most of the walk but two call sites
use the compiled-in default of 16 instead of the configured value.
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_SELF_CONFIG_INVALIDaudit 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.
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 / Advanced Peios / 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.
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.
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 / Advanced Peios / 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/.
| Number | Constant | Signature |
|---|---|---|
| 1100 | SYS_REG_OPEN_KEY | reg_open_key(int parent_fd, const char __user *path, u32 desired_access, u32 flags) |
| 1101 | SYS_REG_CREATE_KEY | reg_create_key(const struct reg_create_key_args __user *args) |
| 1102 | 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 |
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_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.
Syscall and ioctl argument sizes.
| 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_REGISTER_ARGS_SIZE | 24 |
REG_SRC_HIVE_ENTRY_SIZE | 56 |
_IOWR, not _IOR: the kernel reads the caller's name_len and name_ptr out of the argument struct before it writes the result back, so the argument crosses in both directions. It was declared _IOR, which put the wrong direction bits in the encoded number -- and since the kernel dispatches on the whole encoded value, correcting it is a wire break, not a relabelling.
| Constant | Value |
|---|---|
REG_IOC_QUERY_KEY_INFO | 0xC0405207 |
REG_IOC_DELETE_KEY | 0x40185208 |
REG_IOC_HIDE_KEY | 0x40185209 |
REG_IOC_GET_SECURITY | 0xC010520A |
REG_IOC_SET_SECURITY | 0x4018520B |
REG_IOC_NOTIFY | 0x4008520C |
REG_IOC_FLUSH | 0x0000520D |
REG_IOC_BACKUP | 0x4004520E |
REG_IOC_RESTORE | 0x4004520F |
Transaction state codes.
| 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 |
Registry key access rights.
| 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 |
REG_VALID_SECURITY_INFORMATION | 0x0000000F |
Registry value types.
| 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 |
Watch event raw byte layout.
| 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 |
RSI common wire layout.
| 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 |
RSI status codes.
| 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 |
RSI path target types.
| 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 / Advanced Peios / 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.6, the error vocabulary in §5.6.4, 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 they are reachable
from the x32 ABI as well as from x86-64.
1.1 Overview
Peios / Advanced Peios / 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 twice — at boot and on an explicit reload — 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 notify socket, one timerfd per armed timer, a pidfd per supervised process, a pipe pair per service's output, the registry's change-notification descriptor, and the JFS device. Work arriving on any 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.
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 / Advanced Peios / 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 #
Two of peinit's interfaces are specified separately, in PSPU §4: the control socket, spoken by administrative tools and by any program that manages services, and the notification socket, spoken by every service that reports readiness, keepalives, or a stored descriptor. 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)
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.
- JFS — the kernel side of ad-hoc job submission. §8.5 describes what peinit does with what JFS hands it.
- Using peinit — writing service definitions, the
svctlcommand surface, 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 / Advanced Peios / 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, an ad-hoc submission — with a GUID, a lifecycle, a token summary and a log correlation key. Jobs are the observable unit of what actually ran.
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 uses two: a ServiceSecurity descriptor per service, controlling who may manage it, and its own control descriptor, controlling system-level operations. 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.
JFS. The Job Forwarding Subsystem: the kernel bridge that captures a
caller's effective token and delivers it, with a job definition, to
whatever holds /dev/jfs open. peinit is the consumer. §8.5.
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/. §5.1.
sd_notify. The datagram protocol services use to report readiness,
status, 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 / Advanced Peios / 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 |
2.1 The Initramfs Contract
Peios / Advanced Peios / 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 / Advanced Peios / 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 depends on lpsd, lpsd depends on registryd, and registryd has to be running before any of them. 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.
Four services use it:
| Service | Why |
|---|---|
| registryd | Starts before authd exists at all. |
| lpsd | Must be running before authd can resolve a local identity. |
| authd | Needs SeTcbPrivilege and SeCreateTokenPrivilege; it is the minter for everything else. |
| eventd | Starts early, before authd is necessarily available. |
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 and lpsd are 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 / Advanced Peios / 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 |
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.
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 four
peinit owns, a mount failure sends peinit to recovery.
2.3.2.1 Seeding descriptors on the new filesystems #
Three of the four 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 and is marked inheritable by both containers and objects:
O:SY G:SY D:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)
Everything created underneath — the control socket, the notify socket,
per-service runtime directories, the cgroup hierarchy — inherits from
it. 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, and the two are kept identical 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.
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.
Any failure of this step — an unreadable file, a CSPRNG failure, or an
unwritable path — sends peinit to recovery. The write does not create
parent directories, so an image that ships without /lcl/etc/ present
fails here rather than at first use.
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. - The JFS device: peinit opens
/dev/jfsand adds the descriptor to its event loop, enabling ad-hoc job submission once Phase 2 runs. - Loopback: peinit brings up
loover netlink, because services that bind127.0.0.1need it.
Control socket creation failing sends peinit to recovery — without it there is no way to administer the system. The other two are warnings: a JFS open failure, a JFS event-loop registration failure and a loopback bring-up failure all let 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 read, generation, or write fails | Recovery |
| 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 fails | Recovery |
| JFS open or registration fails | Warning; boot continues |
| Loopback bring-up fails | Warning; boot continues |
2.4 Path Provisioning
Peios / Advanced Peios / 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 / Advanced Peios / 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.
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.
2.5.3 Starting #
peinit walks the graph and starts services, parallelising wherever the graph allows, up to a configurable limit:
| Key | Default | Meaning |
|---|---|---|
Machine\System\Boot\MaxParallelStarts | 10 | Services starting concurrently. |
An absent key uses the default. A value of zero, a type mismatch, or a malformed payload is invalid boot configuration and sends peinit to recovery — running the scheduler with an effective limit of zero would hang the boot rather than fail it.
As each service reaches a dependent-satisfying state — Active for
Simple, Completed for Oneshot with or without RemainAfterExit, Skipped
for a service whose conditions did not hold — its dependents become
eligible and join the start queue. A Oneshot without RemainAfterExit
passes through Completed, releasing its dependents, and then goes
Inactive.
Dependents blocked on a Requires or BindsTo target wait for that
target to reach a satisfying state. Dependents blocked on a Wants
target wait only for it to reach any terminal state, satisfying or
not — which is what makes Wants ordering rather than dependency.
2.5.3.1 The typical order #
Nothing below is hardcoded. It falls out of the dependency graph that the standard role definitions produce, and an administrator who changes a dependency gets a different order.
- 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).
2.5.6 Failure summary #
| Failure | Response |
|---|---|
| A service definition fails to decode | Recovery |
| 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 / Advanced Peios / 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 the services that forced
Safe mode are never entered into the blocked set and are never marked
Failed. That is deliberate: Safe mode was never going to start them, and
a Failed state would say something about their own health that is not
true. status should keep meaning "this service is broken".
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
boot.safe_mode_downgrade KMES event naming the services involved.
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.
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:
| Value | Behaviour |
|---|---|
0 | Write unconditionally. |
1 | Do not write to a terminal held as the controlling terminal of a running service, except to announce loss of the system. This is the default. |
2 | Additionally drop ordinary progress everywhere, while still emitting errors. |
The two rules are independent, and an error is never less visible at 2
than at 1. A terminal is matched by device rather than by path, since
/dev/console and /dev/ttyS<n> can name the same device; where the
device cannot be determined, peinit falls back conservatively and treats
the terminal as held. 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 #
| Parameter | Effect |
|---|---|
peios.safemode=1 | Force Safe mode. |
peios.recovery=1 | Force recovery mode regardless of the counter. |
peios.bootattempts=N | Set the recovery threshold; 0 disables the check. |
peios.quiet=N | Console verbosity, as above. |
peios.notifysocket=PATH | Override the notification socket path. |
A malformed value is ignored in favour of the default rather than failing the boot. Nothing exists this early to report a diagnostic to.
2.7 The Boot Attempt Counter
Peios / Advanced Peios / 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.
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 / Advanced Peios / 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.
2.8.2 What peinit does #
peinit records the reason as a KMES audit event, 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.
Whether the earlier steps are re-run depends on where the failure came from. A recovery entered from a Phase 2 or runtime failure has already completed Phase 1 and has registryd running. A recovery entered from a Phase 1 failure — a mount that would not mount, an RTC that would not read, a control socket that would not bind — skips steps 1 and 3 above: it neither completes the remaining Phase 1 steps nor attempts registryd.
2.8.3 Offline registry access #
If registryd is what caused the recovery, the administrator needs tools that work without it. Three paths exist:
| Path | What it does |
|---|---|
loregd --inspector | Reads the storage database directly, bypassing LCS, for diagnosis. |
loregd --recover-from-backup | Restores from the automatic backup taken on every registryd startup. |
loregd --dangerously-clear-database | Wipes the registry. Role definitions are the source of truth for service configuration, so a cleared registry is recoverable. |
These name loregd rather than registryd because in recovery the administrator is interacting with the storage implementation, not with the registry abstraction. It is the one context where that distinction is visible.
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 / Advanced Peios / 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 / Advanced Peios / 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 | Principal for the service token. §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 |
| 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 |
| 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, as 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 whole read is rejected and the previous generation stays in place (§10.4). That is not an inconsistency: a reload is atomic and has a working configuration to fall back to, where a boot has none. Refusing everything is the safe answer only when there is something to keep.
3.3 Field Formats
Peios / Advanced Peios / 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 as service names 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.
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 / Advanced Peios / 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 |
| 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. 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 Disabled #
Disabled=1 suppresses automatic activation and nothing else. No
trigger fires: not boot, not boot:settled, 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.3 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 / Advanced Peios / 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. It can therefore only name a key peinit already
caches — under Machine\System\Services\ or Machine\System\Init\.
Naming any other key is a decode error, caught at load rather than at
start.
Within that, resolution is narrower than the load-time check suggests.
A registry:Machine\System\Services\<name> check is true when that
service exists in the model, which makes it a subkey-existence test
rather than a general key-existence test.
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 / Advanced Peios / 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 / Advanced Peios / 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. Services that are already running keep their pinned definition; a boot-plan service that has not started yet picks up the new one.
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, RuntimeDirectories,
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 / Advanced Peios / 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 or Abandoned is discarded immediately. There is no process to consider.
3.8.2 Running #
An entry in Active, Starting, Reloading, Backoff 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.
While an entry is definition-removed:
- it keeps satisfying its dependents for as long as the instance is alive, because it is still running;
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.
Once the instance exits or is stopped, the entry — including anything in
its fd store (§10.6) — is discarded. A dependent that Requires the
removed service keeps being satisfied while the instance runs; after the
entry is discarded, the dependent's next start sees an unresolved
dependency and takes the ordinary validation path.
4.1 Token Materialisation
Peios / Advanced Peios / 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:
| Identity | Source | Mechanism |
|---|---|---|
SYSTEM | Minted by peinit from its own identity | kacs_create_token. §4.2 |
| Anything else | authd | The token request flow. §4.3 |
| Absent or empty | authd | Defaults to LocalService. |
peinit reads its own token with kacs_open_self_token requesting the
real token rather than any impersonation, and opens it query-only: it is
a template to copy from, never a thing to hand out.
4.1.1 Where a token is materialised #
Materialisation happens at the point of use, per launched process, not once per service. A service that runs a pre-exec hook, a main process, and then a health check materialises three tokens.
| Context | Identity used |
|---|---|
| Main process | Identity |
ExecStartPre / ExecStartPost | HookIdentity if set, otherwise Identity |
| Health checks | Identity, always |
ExecReload external command | Identity, always |
| Ad-hoc jobs | The token JFS captured from the submitter |
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 / Advanced Peios / peinit / Service Identity
For Identity=SYSTEM, peinit mints a token itself. This is what breaks
the bootstrap circle: registryd, lpsd, authd and eventd all need tokens,
and authd — the thing that mints tokens — is one of them.
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.
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 / Advanced Peios / 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.
What peinit requires of authd is:
- peinit sends the
Identityvalue verbatim. - authd routes it to an identity source — a built-in for the well-known principals, lpsd for local accounts, a connector for domain accounts.
- The source returns the principal's user SID and group SIDs.
- authd mints a KACS token, creates a logon session, adds the per-service SID to the group list, and returns the token descriptor to peinit.
Every non-SYSTEM service start depends on this, and every one of them fails if authd is unavailable when the token is needed. Platform services are unaffected, because they never take this route.
4.3.1 The interface is authd's #
The steps above describe what peinit needs, not how it asks. authd owns the request and response schema, the socket path, and the descriptor passing mechanism. No authd specification exists yet — authd's design was deliberately deferred until KACS and the registry had settled — so this path is not implementable from this manual alone.
4.3.2 The current implementation #
peinit's authd client is a placeholder. It ignores the requested identity and returns a freshly minted SYSTEM token, taking the §4.2 path for every service.
The consequence is that every service currently runs with user SID
S-1-5-18 and peinit's full privilege set, whatever its definition
says. RequiredPrivileges still applies, and is currently the only
thing that reduces what a service can do. A service that does not set it
runs fully privileged.
Two things follow that are worth stating plainly, because both are easy to reason wrongly about:
- Status output reports the declared identity, not the effective
one. The job's resolved identity string is what appears in a status
query and in a
job.createdevent, and it saysLocalServicefor a service running on a SYSTEM token. - The per-service SID is still correct. peinit computes it from the service name and adds it to the minted token (§4.4), so per-service ACLs behave as designed even while the user SID does not.
The placeholder also interacts with socket protection in a way worth knowing about. peinit's sockets are reachable only by SYSTEM (§13.3), so a service on a correctly resolved non-SYSTEM token could not reach the notification socket to report readiness. Today every service holds a SYSTEM token, so the question does not arise — which means the two have to be resolved together rather than one at a time.
4.4 Per-Service SIDs
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 #
| Right | Bit | Grants |
|---|---|---|
SERVICE_QUERY_STATUS | 0x0001 | Query state, PID, cause, health, warnings. |
SERVICE_START | 0x0002 | Start the service. |
SERVICE_STOP | 0x0004 | Stop the service. |
SERVICE_INTERROGATE | 0x0008 | Reload the service. |
SERVICE_ALL_ACCESS | 0x000F | The union of the four. |
Restart requires SERVICE_START and SERVICE_STOP together. Reset
requires SERVICE_STOP, because clearing a Failed or Abandoned state is
the tail of stopping something rather than the head of starting it.
The generic mapping peinit passes to AccessCheck:
| Generic 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 |
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.
If that key has no ServiceSecurity either, peinit applies a built-in
default:
O:SY G:SY D:(A;;GA;;;SY)(A;;0x0005;;;BA)
SYSTEM gets full access. Administrators get SERVICE_QUERY_STATUS and
SERVICE_STOP — query and stop, but not start and not reload. The
asymmetry is deliberate: stopping something that is misbehaving is a
containment action, and starting something is a change.
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, and the right the command needs.
- On denial, returns
ACCESS_DENIEDand records the attempt as anaccess.deniedevent carrying the caller's SID, the target, the requested right by name, the requested access bits and the granted bits. - On grant, proceeds.
4.6.4 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.5 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 recorded as audit events rather
than surfaced to the caller, because reporting them would answer the
question the filtering exists to avoid answering.
4.7 The Control Descriptor
Peios / Advanced Peios / peinit / Service Identity
Two operations are not about any one service: shutting the system down,
and re-reading the configuration. They are checked against peinit's own
descriptor, stored at Machine\System\Init\ControlSecurity as a binary
value.
4.7.1 Access rights #
| Right | Bit | Grants |
|---|---|---|
SYSTEM_SHUTDOWN | 0x0001 | Initiate poweroff, reboot or halt. |
SYSTEM_RELOAD_CONFIG | 0x0002 | Re-read all definitions from the registry. |
The generic mapping:
| Generic right | Maps to |
|---|---|
GENERIC_READ | nothing |
GENERIC_WRITE | SYSTEM_RELOAD_CONFIG |
GENERIC_EXECUTE | SYSTEM_SHUTDOWN |
GENERIC_ALL | both |
GENERIC_READ maps to nothing because there is nothing to read: the
control descriptor governs two actions and no queries. A grant of
GENERIC_READ on it is not an error, it simply conveys no access.
4.7.2 The default #
Absent a value in the registry, peinit applies:
O:SY G:BA D:(A;;0x0003;;;SY)(A;;0x0003;;;BA)
SYSTEM and Administrators both get shutdown and reload-config. Unlike the ServiceSecurity default, this one is symmetric — an administrator who can stop services one at a time can already stop the system, so withholding shutdown would be theatre.
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 / Advanced Peios / peinit / Starting a Service
peinit uses cgroups v2 for exactly two things: knowing which processes belong to a service, and killing all of them at once. It does no resource accounting and sets no limits.
Every service gets a tree:
/sys/fs/cgroup/peinit/<cgroup-id>/ service root
/sys/fs/cgroup/peinit/<cgroup-id>/main/ the main process
/sys/fs/cgroup/peinit/<cgroup-id>/hooks/ hooks and reload commands
/sys/fs/cgroup/peinit/<cgroup-id>/health/ health check invocations
/sys/fs/cgroup/peinit/<cgroup-id>/checks/ pre-start filesystem check helpers
The sub-cgroups satisfy cgroups v2's "no internal processes" rule, which applies whenever controllers are enabled, and give hooks and probes containment of their own so that killing one does not touch the service.
They are not all created at once. The root and hooks/ are created when
the first thing needs them — the first pre-exec hook, if there is one —
and main/ and health/ when the main process launches.
5.1.1 The cgroup id #
<cgroup-id> is the service name with every byte outside
[A-Za-z0-9._-] percent-encoded as % plus two uppercase hex digits.
Service names are already restricted to that set (§3.1), so in practice
the id equals the name.
The encoding is a defensive guarantee that distinct names always map to
distinct, cgroup-safe ids. % is not itself in the safe set, so it
escapes to %25 and the encoding is prefix-free. That is what makes it
injective, unlike a plain substitution of / for -, under which a/b
and a-b would collide.
The id is internal. The name a user sees is unchanged.
5.1.2 Generations #
A cgroup whose processes survived SIGKILL cannot be removed — rmdir on
it fails with EBUSY. When peinit detects that a service's tree still
has live processes after the post-kill deadline, it records the leak and
increments the service's cgroup generation. The next start uses a fresh tree:
/sys/fs/cgroup/peinit/<cgroup-id>.gen<N>/
Old leaked trees persist until the next reboot.
The generation counter advances once per recorded leak rather than once
per restart, and leaks are deduplicated by path and kind. A single
failed start can record two — one for hooks/ and one for the service
tree — so the number can advance by more than one at a time.
Because . is a legal character in a service name and the generational
suffix uses one, the tree path is not injective the way the id is: a
service literally named app.gen1 and generation 1 of a service named
app resolve to the same directory.
5.2 Pre-Start Evaluation
Peios / Advanced Peios / 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.
- 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 both 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.
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.
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 / Advanced Peios / 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.
The step identifiers:
| 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.
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
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 / Advanced Peios / 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.
| # | 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 | Install the KACS token, then close its descriptor | 4 |
| 7 | Set RLIMIT_NOFILE and RLIMIT_CORE | 5 |
| 8 | Set oom_score_adj | 6 |
| 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 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.6 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.
The exception is a descriptor opened through the Peios native file interface, which returns without close-on-exec set. peinit repairs that where it opens input devices for the power button; the JFS device descriptor and each service's own cgroup directory descriptor are not repaired, and are inherited across exec.
5.5 The Base Environment
Peios / Advanced Peios / 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 and LISTEN_FDNAMES, only when
descriptors are being injected from the fd store.
These have the highest precedence and are inserted after both
configurable layers, so a service cannot override NOTIFY_SOCKET and
break its own notification protocol. The guard is insertion order rather
than a reserved-name check, which means the two fd-store variables are
protected only when they are actually being set — with no descriptors to
inject they are not inserted, and a value from either configurable layer
reaches the child unchanged.
LISTEN_PID, which a conforming sd_listen_fds implementation checks
against its own PID before trusting LISTEN_FDS, is not set.
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.
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 / Advanced Peios / 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 also counted as a failure and escalates immediately.
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 is checked for any service that declares a HealthCheck,
including a Oneshot — even though health checks are scheduled only for
Simple services, so the check being constrained would never run.
5.7 Leaked Sub-Cgroups
Peios / Advanced Peios / 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.
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
cgroup.leakedevent on the event stream, carrying the service, the sub-cgroup path, its kind, and the detection time in monotonic nanoseconds. -
A console line naming the same service, kind and path.
The pull side survives the moment of detection, for anyone who was not watching:
-
A status query includes a
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 / Advanced Peios / 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 three states satisfy dependents: Active, 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.
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.
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 / Advanced Peios / 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. |
| Backoff | Starting | The backoff delay elapsed. Cause RestartPolicy. |
| Backoff | Inactive | An explicit stop cancelled the pending restart. |
| 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, so a restarting service is briefly observable as Inactive.
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.
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 / Advanced Peios / 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. |
ProcessUnkillable | Abandoned | Processes survived SIGKILL. |
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. Retrying cannot help with any of
them.
6.3.3 OnFailure #
When a service enters Failed and its definition names an OnFailure
service, peinit starts that service — with the exceptions below.
OnFailure fires on entry to Failed, and Failed to Failed is not a
transition, so it fires at most once per failure.
It does not fire for:
ShutdownWave— no new service starts during shutdown, so a fallback would be both impossible and pointless.ValidationError,CycleDetected,DependencyFailure,AssertionError— these are definition or graph breakage rather than runtime degradation. A broken definition cannot meaningfully trigger a fallback, and the fallback would probably sit in the same broken graph.
It fires for everything else, including ProcessCrash,
WatchdogTimeout, HealthCheckFailure, the startup failures, and
RestartBudgetExhausted on a non-Critical service.
For a Critical service exhausting its budget, the reboot takes
precedence and no fallback is started. The suppression keys on the cause
and the service's ErrorControl rather than on whether a reboot was
actually scheduled.
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 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 an
on_failure.loop_suppressed event naming which, and stops.
The chain is cleared when a handler reaches Completed, Inactive, Skipped or Abandoned. A handler that starts and stays running keeps its entry, so it continues to occupy a slot of that originating failure's budget.
6.3.4 The logging contract #
Every state transition produces a record covering four things: what failed, why it failed, what peinit did about it, and what the administrator should do. 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 / Advanced Peios / 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
// Always falls through unconditionally.
// 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 exit code is available only when peinit observed a process exit. For
a Simple service that exits before signalling readiness the code is not
carried into the evaluation, so the SuccessExitCodes branch of the
OnFailure policy cannot apply and such a service is always restarted.
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.
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 is driven from the paths that observe a terminal outcome for
a running service: the main job ending, a health check failing or timing
out, and the watchdog expiring. A budget exhausted purely by startup
failures — repeated ReadinessTimeout, repeated PreHookFailure — does
not reach one of those paths, so a Critical service that can never get
as far as running settles in Failed rather than rebooting, and its
OnFailure handler is suppressed as well.
6.5 Reload
Peios / Advanced Peios / 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. It is bounded from above by the operation's own deadline, so a
service whose StartTimeout is under two seconds gets the shorter of
the two.
The extended-wait expiry means the service announced a reload and never
finished one. The outcome is carried in the operation's result, which a
wait=true caller receives; a reload issued without waiting — the
default — resolves silently.
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 / Advanced Peios / 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 the extension applies only to a service whose stop wave has already begun. A service in a later wave, or one still winding down a start or a reload when shutdown was requested, has no shutdown deadline recorded yet and its extension request has no effect.
7.1 Relationships
Peios / Advanced Peios / 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 / Advanced Peios / peinit / Dependencies
peinit validates the dependency graph before executing it. Validation runs once per graph build — at boot, on an 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. |
Detection is a Full-mode behaviour. In Safe mode a hard-dependency target that is missing, disabled, or not Safe-mode-eligible does not block the dependent, which is started with the dependency unmet.
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.
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 graph.validation_error KMES
event, carrying phase: "boot". 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 finding value 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 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.3 Graph Execution
Peios / Advanced Peios / 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.
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.
Contexts are not retired. A context and its operation associations persist for the lifetime of the process.
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.
The on-demand path treats a disabled hard-dependency target differently from the boot path: where boot blocks the dependent, an on-demand start includes the disabled target and starts it.
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 / Advanced Peios / 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, in Full mode only | Blocks the dependent |
| A disabled hard-dependency target | Blocks the dependent | Starts it |
| 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. At boot it fails that service withValidationErrorand the rest continue; on a reload it rejects the whole reload (§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.
8.1 Jobs
Peios / Advanced Peios / 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, an ad-hoc submission.
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 ad-hoc
job_type: enum // ServiceMain, PreExecHook, PostExecHook,
// ReloadHook, HealthCheck, AdHoc
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 ad-hoc
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.- 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.
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.
8.1.4 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 |
A service tracks its current main job's identifier, and a status query returns it.
8.2 Operations
Peios / Advanced Peios / 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. |
| Merged | Merged into an existing identical operation, whose identifier is recorded. |
| Cancelled | Terminated while Pending. It never executed. |
| Aborted | Terminated while Running. |
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.
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. |
Shutdown is declared and labelled but not currently produced: shutdown
transitions services and signals them directly, without creating
operations for the stops (§12.2).
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.
The clock starts at creation, including queue time. 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 / Advanced Peios / 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.
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, and a new Start while a Reload is active.
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.
8.4 Event Emission
Peios / Advanced Peios / peinit / Jobs and Operations
peinit emits a structured event at every job and operation lifecycle
transition, and for its own audit records. All of them go into the KMES
kernel ring buffer through kmes_emit and kmes_emit_batch, 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 Job events #
job.created — the job object exists. Carries the job identifier,
service name, type, image path, identity and operation identifier.
job.started — exec succeeded. Carries the job identifier, PID and
cgroup path.
job.ended — the process exited or was killed. Carries the job
identifier, final state, exit code or signal, duration and failure
cause.
The event type is the dotted string; the fields form the msgpack
payload. The payloads are supersets of the summaries above — job.ended
in particular carries the whole record.
8.4.2 Operation events #
Every operation event carries: operation_id, type, service,
source, caller — null for a lifecycle-generated operation — and
state after the transition.
| Event | Adds |
|---|---|
operation.requested | — |
operation.started | — |
operation.completed | duration_ns, result |
operation.failed | duration_ns, failure_reason |
operation.cancelled | reason |
operation.merged | merged_into |
operation.aborted | duration_ns, reason |
duration_ns is measured from creation, not from the start of
execution — the same reasoning as the operation timeout. What a caller
waited is what matters, and queue time is part of it.
The same field appears under three names across the two surfaces:
failure_reason on operation.failed, reason on
operation.cancelled and operation.aborted, and error in the
control interface's operation view (PSPU §4).
8.4.3 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
operation.started.
8.4.4 Audit and graph events #
peinit's own audit records go through the same path: access.denied for
a refused control command, with the caller's SID, the target, the
requested right by name and the access bits requested and granted;
on_failure.loop_suppressed when the fallback chain guard trips;
graph.validation_error and graph.validation_warning for validation
findings; notify.rejected for an unauthenticated notification;
fd_store.rejected for a refused descriptor; notify.status,
notify.errno and notify.exit_status for the three event-emitting
notification fields; cgroup.leaked the first time a sub-cgroup is found
still populated after its post-kill deadline (§5.7); and
graph.operation_terminal for a graph member's terminal outcome.
Audit records are events rather than logs, and that distinction is the point: the ring buffer persists from the moment PKM loads, so an access denial during Phase 1 is captured before the registry exists, let alone eventd.
8.5 Ad-Hoc Jobs
Peios / Advanced Peios / peinit / Jobs and Operations
An ad-hoc job is an arbitrary supervised process submitted by a service on behalf of its own client. It has no persistent definition: it runs once, reports, and is cleaned up.
8.5.1 The delegation problem #
A service — a privileged action broker, say — wants peinit to run a process as one of its users. It has impersonated that user's token, but KACS will not let it forward that token over IPC without Delegation-level impersonation. Fork inheritance works, because the kernel copies the token naturally, but then the service has to supervise the process itself, which defeats the point of having a service manager.
JFS — the Job Forwarding Subsystem — is the kernel's answer. It captures
the caller's effective token and delivers it, with a job definition, to
whatever holds /dev/jfs open. peinit is the consumer, and JFS is a
generic primitive rather than something built for peinit.
peinit opens /dev/jfs during Phase 1 infrastructure setup and adds the
descriptor to its event loop. If nothing has the device open, a
submitter's syscall returns ENODEV.
8.5.2 The shape of a request #
handle_jfs_request(request):
(job_definition, token_fd) = read from /dev/jfs
validate the image path, arguments, working directory
job = Job { id: new_guid(), service: null, type: AdHoc,
state: Created, token_summary: summarise(token_fd), ... }
write job.id back to /dev/jfs // unblocks the caller
fork, install token_fd on the child, exec
emit job.created, job.started, job.ended
8.5.3 The definition #
A subset of the service definition's fields, arriving as structured data rather than as registry values:
| Field | Required | Meaning |
|---|---|---|
| ImagePath | yes | The binary to execute. |
| Arguments | no | Its arguments. |
| Environment | no | Additional variables, as a map rather than KEY=VALUE strings. |
| Timeout | no | Maximum runtime in seconds. 0 means no limit. |
| WorkingDirectory | no | Defaults to /. |
| Description | no | For logs. |
The service-level fields deliberately absent are the policy ones:
RestartPolicy and its parameters, the four dependency fields,
HealthCheck, WatchdogTimeout, ErrorControl, SafeMode and
Triggers. Those belong to a persistent definition; an ad-hoc job runs
once.
8.5.4 Identity #
The job runs with the token JFS captured — the caller's effective identity at the moment of the syscall. An impersonating caller produces a job running as the impersonated user; a caller using its own primary token produces a job running as itself.
There is no identity field. A submitter cannot name an arbitrary identity, only pass through the one it already holds. That is what stops the mechanism being an escalation: a service cannot create jobs as principals it could not already act as.
8.5.5 Lifecycle #
- Forked with the JFS-provided token.
- Runs in its own cgroup under
/sys/fs/cgroup/peinit/, the id derived from the job's GUID rather than from a service name. - Output routed to eventd, tagged with the job's GUID.
- On exit: emit
job.ended, clean up the cgroup, drop the job. - No restart, no dependencies, no health checks.
Exceeding Timeout sends SIGTERM, waits the schema default
StopTimeout of 10 seconds, then SIGKILL — the same escalation as a
service stop.
Ad-hoc jobs bypass the operations model entirely. A JFS request creates a job directly, because there is no service to start and therefore no state machine action to represent:
Admin --> start command --> Operation --> peinit forks --> Job
Broker --> JFS request --> peinit forks --> Job
Timer --> timerfd fires --> Operation --> peinit forks --> Job
8.5.6 The current state of JFS #
JFS does not exist in the kernel. There is no /dev/jfs device, no
request encoding, and no mechanism for delivering a captured token
descriptor across a device read.
peinit's side is built up to that boundary and stops there. Phase 1
opens /dev/jfs if it is present, and a failure to open is a warning
that does not affect the boot. The descriptor is registered with the
event loop, and on the first readable event peinit reads nothing,
unregisters the descriptor and permanently disables the source.
The job machinery for ad-hoc jobs exists as far as the record: the job
type, the GUID-derived cgroup id, and a constructor. There is no launch
path — an ad-hoc job cannot currently be started — and the definition
fields above have no decoder to arrive through, so Timeout,
Environment and WorkingDirectory have nowhere to come from.
The byte-level /dev/jfs interface belongs to JFS rather than to
peinit, and this chapter describes the shape of the consumption
protocol rather than its encoding.
8.6 What a Caller Sees
Peios / Advanced Peios / 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.
Commands that never create an operation — status, list,
operation-status — have their own shapes.
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 operation carries the resulting service state. A failed one carries the failure reason. 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.
9.1 Calendar Expressions
Peios / Advanced Peios / 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 / Advanced Peios / 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. 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
(_, Inactive | Completed | 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.
The last-run write happens in a forked child so that the event loop never waits on the registry. The parent returns immediately, and the child is reaped as an untracked orphan; a failed write is visible only as that child's exit status.
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.3 Persistence
Peios / Advanced Peios / 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 re-arms every timer from the current time with no
catch-up, whatever TimerPersistent says. History is consulted at boot
only.
9.4 Jitter and Clocks
Peios / Advanced Peios / 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 / Advanced Peios / 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.
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 inheritance. /run is a tmpfs peinit mounts
itself in Phase 1, and a fresh tmpfs carries no descriptor at all, which
under DENY_MISSING would leave every inode on it unreachable to
everything. So peinit stamps the mount root with an inheritable
descriptor as soon as it mounts it (§2.3):
O:SY G:SY D:(A;OICI;GA;;;SY)
Every inode created underneath inherits from it, the two sockets
included. The parent directory /run/services/peinit/ is created
plainly, with no descriptor of its own, so it inherits too.
The effect is that both sockets are reachable by SYSTEM and by nothing
else. The single inheritable entry grants GENERIC_ALL to S-1-5-18
and names no other principal, and connecting to a pathname socket is
checked against the socket inode's descriptor before any peer identity
is established.
10.1.2 Connections #
peinit accepts a connection, obtains the peer's token, and only then admits it against the connection limit:
| Key | Default | Meaning |
|---|---|---|
Machine\System\Init\MaxControlConnections | 32 | Concurrent connections. |
Machine\System\Init\MaxRequestSize | 65536 | Maximum request size, in bytes. |
Machine\System\Init\ConnectionTimeout | 30 | Seconds before an idle connection is closed. |
A connection over the 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.
10.1.3 The peer token #
The token is captured once, when the connection is accepted, using
kacs_open_peer_token. It is the peer thread's effective token at
that moment, 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, or with output still buffered, is never idle
and is never closed by ConnectionTimeout — it stays open until the
operation resolves, bounded by the operation's own timeout rather than
the connection's.
peinit handles one frame per readiness turn, and reads 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 / Advanced Peios / 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,listandoperation-statusis rejected. 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. peinit does not synthesise a descriptor for something that does not exist. - AccessCheck. The caller's token, the target's descriptor, the generic mapping, and the right the command requires (§4.6, §4.7).
- On denial, return
ACCESS_DENIEDand record anaccess.deniedevent carrying the caller's SID, the target, the requested right by name, and the access bits requested and granted. Silent denial is not acceptable; a denial an administrator cannot see is indistinguishable from a bug. - On grant, classify the command against the service's state (§10.3) and act.
10.2.1 Rights #
| Command | Right |
|---|---|
start | SERVICE_START |
stop | SERVICE_STOP |
restart | SERVICE_START and SERVICE_STOP |
reload | SERVICE_INTERROGATE |
reset | SERVICE_STOP |
status | SERVICE_QUERY_STATUS |
list | Filtered per service by SERVICE_QUERY_STATUS |
operation-status | SERVICE_QUERY_STATUS on the target service |
shutdown | SYSTEM_SHUTDOWN |
reload-config | SYSTEM_RELOAD_CONFIG |
operation-status resolves the operation before it checks the right, so
an unknown identifier is reported as unknown regardless of who asked.
10.2.2 Filtering #
list checks every service and partitions the result. Services the
caller can query are returned; services it cannot are omitted, and the
denials become audit events rather than anything the caller sees. A
caller with no query rights anywhere gets an empty list and a successful
response, not a denial — the filtering exists to avoid answering the
question "does this service exist", and reporting the denials would
answer it.
Definition-removed services are listed, and the list entry does not say
so. A status query on one does.
10.3 The Command × State Matrix
Peios / Advanced Peios / 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.
| Inactive | Starting | Active | Reloading | Stopping | Completed | Backoff | Failed | Abandoned | Skipped | |
|---|---|---|---|---|---|---|---|---|---|---|
| start | Start | MERGE | ALREADY | ALREADY | QUEUE | Start | DEFER | Start | ERROR | Start |
| stop | NOOP | Cancel+Stop | Stop | Stop | MERGE | Clear | Cancel | NOOP | ERROR | NOOP |
| restart | Start | QUEUE | Restart | Restart | QUEUE | Start | Restart | Start | ERROR | Start |
| reload | ERROR | ERROR | Reload | MERGE | ERROR | ERROR | ERROR | ERROR | ERROR | ERROR |
| reset | NOOP | ERROR | ERROR | ERROR | ERROR | ERROR | ERROR | Clear | Clear | Clear |
| status | OK | OK | OK | OK | OK | OK | OK | OK | OK | OK |
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.restartcancels the automatic restart and queues an administrator-initiated one.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.
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.
10.4 reload-config
Peios / Advanced Peios / 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.
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 and every calendar timer is re-armed, from the current time and with no catch-up.
A reload also refreshes things that are not service definitions: the control descriptor, the three 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 / Advanced Peios / 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=.
There is one socket, not one per service, and the bind unlinks any stale path first. It carries the same inherited descriptor as the control socket (§10.1).
The protocol itself is PSPU §4. What follows is how peinit decides whether to believe a datagram.
10.5.1 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. - 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.
Anything that fails is dropped and recorded as a notify.rejected event
carrying the sender's PID and the reason.
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.2 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.3 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.
Three are event-emitting. STATUS=, ERRNO= and EXIT_STATUS= are
authenticated and then emitted as KMES events — notify.status,
notify.errno, notify.exit_status — whose payloads carry the service
name, the job identifier, the operation identifier and the activation
generation, alongside the value. They take the same path as job and
operation events, not a forward to eventd.
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.
ERRNO= and EXIT_STATUS= are not stored. They are emitted and
otherwise not retained.
10.5.4 Bounds #
A datagram is read into a fixed 64 KiB buffer, and the control message
buffer is sized for 64 descriptors. Neither MSG_TRUNC nor MSG_CTRUNC
is inspected, so a larger datagram is truncated silently and descriptors
beyond the sixty-fourth are dropped by the kernel before peinit sees
them.
10.6 The Fd Store
Peios / Advanced Peios / 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 an fd_store.rejected event carrying the outcome
and the reason, 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.
Both 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 not set.
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.
11.1 Wiring
Peios / Advanced Peios / 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.
Health check output is captured, which is usually the only way to find out why a health check failed.
11.1.3 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.
11.2 The Pre-Eventd Buffer
Peios / Advanced Peios / 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, in a bounded buffer that 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 / Advanced Peios / peinit / Output Handling
A noisy service cannot be allowed to starve the event loop. Three bounds apply.
| Key | Default | Minimum | Meaning |
|---|---|---|---|
Machine\System\Init\MaxLogLineLength | 8192 | 256 | Bytes per line before truncation. |
Machine\System\Init\MaxLogBufferPerService | 65536 | 4096 | Bytes buffered per service pipe before backpressure. |
Machine\System\Init\LogReadBytesPerEvent | 16384 | 512 | Bytes drained from one pipe per readable event. |
A value below the minimum is not honoured and does not fail the
boot: the compiled-in default is used and a warning is logged naming the
key, the configured value and what was used instead. The same rule
covers PreEventdBuffer (§11.2), and it is the rule peinit already
applies to the equivalent kernel command-line knobs — a typo in a
logging knob must not decide how the machine boots, and must not be
silent either.
MaxLogBufferPerService's minimum is the one with an external cause:
F_SETPIPE_SZ will not go below one page and rounds up regardless, so a
smaller number in the registry would describe a pipe that does not
exist. The other minimums are engineering judgement — the point below
which the mechanism stops working rather than working differently.
A line exceeding MaxLogLineLength is truncated and marked
[truncated], with the content trimmed so that content and marker
together come to exactly the limit. Everything up to the next newline is
then suppressed rather than emitted as a second line.
MaxLogBufferPerService is applied as the pipe's own capacity through
F_SETPIPE_SZ, which is a literal reading of "bytes buffered per
service pipe before backpressure" — the kernel does the buffering and
the bound is where it belongs.
LogReadBytesPerEvent bounds one readable event rather than one loop
iteration. A turn with several ready pipes reads up to the budget from
each, bounded overall by how many events one epoll wait returns.
11.3.1 Where loss is allowed and where it is not #
At the pipe stage, peinit does not drop. It keeps reading within its
budget and appends every complete line; only EAGAIN, end of file, or
an error stops the read. Backpressure through the pipe is the
flow-control mechanism between a service and peinit, and dropping there
would replace it with silence.
Downstream of the pipe, delivery is loss-tolerant by design. The pre-eventd buffer drops its oldest entries when full, and eventd's datagram socket may drop records under load. The no-silent-drop guarantee covers reading the pipe, not delivery to eventd.
Audit events are exempt from all of it. They go through KMES.
11.3.2 Event loop fairness #
No source may starve the loop. Signals are handled at the highest priority — SIGCHLD reaping and shutdown handling take precedence over everything else in every iteration — with the shutdown deadline timer next and every other source below that, ties broken by arrival order.
The power button shares the top priority with signals, on the reasoning that someone physically pressing it is asking for the same class of attention.
11.4 The eventd Handoff
Peios / Advanced Peios / 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 The record #
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. Omitted when absent. |
The map has four or five entries depending on whether a job identifier applies.
11.4.2 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 outbound write buffer. It sends each record as a datagram and accepts that some may be dropped. It never blocks on a send, and never lets pending records grow without bound.
Each send opens a datagram socket, sends, and closes it. Records go one at a time; there is no batching of several records into one datagram.
A send that fails — the receive buffer full — takes peinit out of forwarding for the remainder of that turn: the failing record and the rest of its batch go back into the pre-eventd buffer. The end of the turn re-establishes forwarding by replaying them.
11.4.3 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 / Advanced Peios / 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 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.
12.1 Triggers
Peios / Advanced Peios / 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 / Advanced Peios / peinit / Shutdown
12.2.1 Step 1: Enter the shutdown state #
peinit sets an internal flag. While it is set, no new service starts,
and control commands other than status, list and operation-status
are rejected as invalid for the current state.
Timer triggers are not disarmed. A calendar timer that fires during the shutdown window is still classified and acted on: for a service in Inactive, Completed or Failed — precisely the states step 3 classifies as not participating — that means creating a start operation and starting the service. The shutdown plan was fixed when the shutdown began, so a service started this way is in no wave, is not waited for, and is not reached by the global-timeout sweep.
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.
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.
12.2.4.1 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 — and it fails the whole shutdown, not just that participant, so a shutdown command with one such service is refused, and one discovered on a later wave ends the runtime loop.
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.
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 / Advanced Peios / peinit / Shutdown
PID 1 handles every signal through a signalfd. All signals are blocked and read from the event loop, so there are no signal handlers and no async-signal-safety concerns anywhere in peinit.
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 / Advanced Peios / 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, and the check for those two cannot read the file
it needs. The result is a recorded cleanup failure and a pointless
read-only remount attempt at the tail of every graceful shutdown.
13.1 Trust Boundaries
Peios / Advanced Peios / 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 / Advanced Peios / 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. |
| 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 does not verify at startup that it holds any of these. A missing
SeCreateTokenPrivilege surfaces as an EPERM from the first token
mint, which is the first service start.
SeImpersonatePrivilege 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.
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 / Advanced Peios / peinit / Security
| Surface | Reachable by | Controls | Protected by |
|---|---|---|---|
| Control socket | Anything that can connect | Service lifecycle, shutdown | The socket inode's descriptor, then the peer token and AccessCheck against the target's descriptor |
| 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 |
| JFS device | Whatever holds the submission privilege | Ad-hoc job submission | A KACS privilege check, kernel-enforced |
13.3.1 What the socket descriptors mean in practice #
Both sockets inherit the single-entry descriptor peinit stamps on /run
in Phase 1 (§2.3), which grants GENERIC_ALL to SYSTEM and names no
other principal.
So both are reachable by SYSTEM alone. A connection from a
non-SYSTEM principal is refused at connect(), by the filesystem, before
peinit ever obtains a peer token — which means the ACCESS_DENIED path,
the audit event, and the Administrators entries in both default
descriptors (§4.6, §4.7) are unreachable for such a caller.
Since every service currently receives a SYSTEM token (§4.3), nothing observes this today: every notifier and every client is SYSTEM. The two have to move together, because a service correctly resolved to a non-SYSTEM identity could not reach the notification socket to report that it had started.
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 / Advanced Peios / 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".
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 (§10.2).
4. peinit records every access denial. A failed AccessCheck produces
an access.denied event carrying the caller's SID, the target, the
requested right by name, and the access bits requested and granted.
Silent denial is not acceptable.
5. The control descriptor and the ServiceSecurity 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 two descriptors, sourced from the registry with a compiled-in default.
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 while the
authd client returns a minted SYSTEM token for every identity (§4.3), a
service that declared nothing runs on one.
14.1 Losing the Registry
Peios / Advanced Peios / 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 a Phase 1 failure does not have registryd running, and the offline tools (§2.8) are what an administrator has.
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 whole reload is rejected
and the previous generation is left in place.
Both are the safe answer for their caller. A reload is atomic and has a working configuration behind it, so refusing the change costs nothing. A boot has no previous generation to fall back to, 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 / Advanced Peios / 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.
14.2.3 JFS #
The device may not exist. Phase 1 treats a failure to open /dev/jfs as
a warning and continues, and the boot is unaffected. Nothing else in
peinit depends on it.
14.2.4 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.5 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 / Advanced Peios / 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.
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 dropped with no record. |
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 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.3 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 / Advanced Peios / 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 service for output pipes, one per armed timer, one per control connection, plus the sockets, the epoll instance, the signalfd and the JFS device.
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.
Two paths leak descriptors slowly: the pre-start check helper's result descriptor and pidfd are unregistered from the event loop but not closed, so a service using filesystem conditions leaks two per start.
14.4.2 Processes #
EAGAIN from clone3 — the PID limit — is also ParentSetupFailure
and restart-eligible.
The one path that leaks processes is the timer last-run write, which forks a child per firing (§9.2). The children are short-lived and reaped by the ordinary PID 1 reaper, but the fork is real and happens on every firing of every persistent timer.
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.
Two things do accumulate. Graph execution contexts and their operation
associations are never retired, so each boot and each on-demand start
adds one for the life of the process. And an OnFailure chain entry for
a handler that starts and stays running is never cleared, so it
permanently occupies 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 / Advanced Peios / 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 I/O failure while reading, generating, or writing does send peinit to recovery.
The seed is entirely fail-soft. Absent, empty, oversized, or unrestorable all continue the boot silently. 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 is reset only by a successful boot — so a power loss during a shutdown leaves the counter advanced. Enough of them in a row reach the recovery threshold, which is the intended behaviour: a machine that keeps losing power mid-shutdown is a machine an administrator should be looking at.
Appendix A Registry Key Reference
Peios / Advanced Peios / 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>\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, both rights | The descriptor for system-level control operations. | §4.7 |
Machine\System\Init\MaxControlConnections | dword | 32 | Concurrent control socket connections. | §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\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 / Advanced Peios / 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. |
/sys/fs/cgroup/peinit/ | The root of every service cgroup tree. |
/dev/jfs | The job forwarding device. |
/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. |
| 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 | |
| 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) |
/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:SYD:(A;;GA;;;SY)(A;;0x0005;;;BA) |
| The default ControlSecurity | O:SYG:BAD:(A;;0x0003;;;SY)(A;;0x0003;;;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 |
B.7 Identifiers #
Every GUID peinit generates — jobs, operations — is UUIDv7, so identifiers sort by creation time.
1.1 Overview
Peios / Advanced Peios / 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.
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 in-memory 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 / Advanced Peios / 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 in-memory SQLite database backing one hive's
volatile keys, attached to that hive's connections under the schema name
volatile (§3.3). It mirrors the hive database's tables, holds no data at
startup, and is destroyed with the process.
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 / Advanced Peios / 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:
| Condition | Reason |
|---|---|
| No hive arguments at all | At least one hive is required. |
An argument with no = | Not a hive declaration. |
| An empty hive name or empty path | Neither is meaningful. |
| A relative database path | Paths are required to be absolute. |
A hive name containing \, /, or NUL | These are path separators and terminators in the registry namespace. |
The hive name CurrentUser, in any case | Reserved by the kernel as a per-token alias; no source may claim it. |
| Two declarations of the same hive name | Duplicates are detected on the folded name, so Machine and MACHINE collide. |
Hive-name comparison is case-insensitive throughout — for duplicate detection here, and for routing requests later — but the case as written on the command line is preserved and is what loregd presents to the kernel when it registers.
2.1.2 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 also opens /dev/console for writing at startup and, if that
succeeds, redirects its own diagnostic log there. This is what makes
loregd's failures visible during early boot, before any log daemon
exists.
2.2 Startup Sequence
Peios / Advanced Peios / 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 0755), then open — or create — the SQLite database at
that path. loregd owns its storage location, so a first boot onto an
empty /var/state is expected to work without anything having prepared
the directory.
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=25000— 25 seconds (§4.3).- 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 an in-memory SQLite database, attached to its connections
under the schema name volatile (§3.3). The attach happens as part of
opening the connection in step 2; the volatile tables are created in
step 4.
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.
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.3 Exit and Shutdown
Peios / Advanced Peios / 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), and every hive's read connections and write connection are closed.
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 does not survive. The in-memory databases holding volatile keys are destroyed with the process (§3.3), which is the entire point of volatility. When the kernel observes the source disconnect, it marks every hive loregd served as unavailable.
3.1 Schema Version
Peios / Advanced Peios / 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:
| State | Behaviour |
|---|---|
No schema_version table | The database is new. loregd creates the persistent tables, the volatile tables, and inserts version 1, all in one transaction. |
| Version equals 1 | Normal startup. |
| Version greater than 1 | Startup fails. The database was written by a newer loregd, and proceeding risks corrupting it by writing through an older understanding of its layout. |
| Version less than 1 | Startup fails. |
There are no migrations. loregd carries no migration table, no migration step list, and no upgrade path; an older database is reported as requiring migration and startup stops there. Since 1 is the only version ever assigned, this does not arise in practice — but a second version cannot be stamped without building the migration machinery first.
Two details of the check are worth knowing. The schema_version table
has no primary key, uniqueness constraint, or check constraint, so a
second row is not detected: the first row read wins. And because every
table is created with IF NOT EXISTS, a database holding the data tables
but no schema_version table is stamped as version 1 without any
validation that its contents match that layout.
3.2 Persistent Tables
Peios / Advanced Peios / 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
);
| Column | Meaning |
|---|---|
guid | The 16-byte key GUID assigned by the kernel. Primary key. |
name | The key's own name component, with case preserved as written. |
name_folded | The folded form of name (§3.4), used for case-insensitive lookup. |
parent_guid | The parent key's GUID; null for the hive root, which is how the root is identified. |
sd | The security descriptor, in binary self-relative form. Opaque to loregd. |
volatile | 1 for a volatile key, 0 for a persistent one. In this table it is always 0 — volatile keys live in the volatile database (§3.3). |
symlink | 1 if the key is a symbolic link. |
last_write_time | Unix nanoseconds. |
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.
| Column | Meaning |
|---|---|
parent_guid | The parent key's GUID. |
child_name | The child name with case preserved. |
child_name_folded | The folded form, which is what the primary key uses — so a name collides case-insensitively within a layer. |
layer | The layer name. Compared as binary, so layer names are case-sensitive, unlike key names. |
target_type | 0 for a GUID entry (the key exists in this layer), 1 for HIDDEN (a tombstone masking lower layers). |
target_guid | The target key's GUID when target_type is 0; null for HIDDEN. |
sequence | The kernel-assigned sequence number. |
The partial index on target_guid covers only non-HIDDEN rows. It is
what makes the reverse lookup — which path entries point at this key —
cheap, and that reverse lookup is what orphan detection (§2.2, step 6)
and RSI_DROP_KEY need.
3.2.3 values #
[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)
);
| Column | Meaning |
|---|---|
key_guid | The key this value belongs to. |
name | The value name, case preserved. The empty string is the key's default value. |
name_folded | The folded form; also the empty string for the default value. |
layer | The layer this value entry belongs to. |
type | The registry value type — REG_SZ is 1, REG_DWORD is 4, and so on. REG_TOMBSTONE (0xFFFF) marks a per-value tombstone. |
data | The value payload; null for a tombstone. |
sequence | The kernel-assigned sequence number. |
values is a reserved word in SQL, so every reference to this table is
quoted — [values], or main.[values] and volatile.[values] when the
schema is named explicitly. Unquoted, it is a syntax error.
3.2.4 blanket_tombstones #
(
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 / Advanced Peios / loregd / Storage
Volatile keys exist only for the lifetime of the running system, so they
never reach the hive's database file. Each hive instead gets a second
SQLite database, held entirely in memory, attached to its connections
under the schema name volatile:
file:<HiveName>_volatile?mode=memory&cache=shared
The hive name in the URI is the case-preserved name from the command line, which is what keeps one hive's volatile database distinct from another's.
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 #
A shared-cache in-memory database exists as long as at least one connection has it open. The write connection creates the volatile tables and holds the database; the read connections and any snapshot connection attach the same URI and see the same data through the shared cache.
Nothing persists it, and nothing tries to. When loregd exits, the memory goes with the process and every volatile key in every hive ceases to exist. That is the entire meaning of volatility here, and it is why the volatile tables are always empty at startup (§2.2, step 7).
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 / Advanced Peios / 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 #
loregd derives the folded form from the Go standard library's
unicode.ToLower, with three corrections where lowercasing and simple
case folding disagree:
| Codepoint | Folds to | Why the correction |
|---|---|---|
| U+00B5 MICRO SIGN | U+03BC GREEK SMALL LETTER MU | Lowercasing leaves it unchanged. |
| U+017F LATIN SMALL LETTER LONG S | U+0073 LATIN SMALL LETTER S | Lowercasing leaves it unchanged. |
| U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE | unchanged | Its folding is a two-codepoint sequence, which simple folding does not produce. |
The standard library's case tables are Unicode 15.0.0.
4.1 Connections
Peios / Advanced Peios / 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.
| Connection | Count | Used for |
|---|---|---|
| Write | One per hive | Every mutating operation, and every read inside a bound read-write transaction. |
| Read pool | min(NumCPU, 16), at least 1 | Reads outside a transaction. Selected round-robin. |
| Snapshot | One per active read-only transaction | Reads inside a read-only transaction. Created on demand, closed when the transaction ends. |
The pool size is compiled in and cannot be configured — loregd reads no configuration (§2.1).
4.1.1 One connection per handle #
Every one of these is a Go database/sql handle limited to a single
underlying connection. That single limit is what serialises writes:
there is no separate executor or lock arbitrating the write path, only
the fact that the hive's write handle can hand out one connection at a
time, so a second writer waits for the first to release it.
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 once, when the connection is opened (§2.2):
journal_mode=walon the hive database, verified after being set.foreign_keys=ON.busy_timeout=25000— 25 seconds (§4.4).- The volatile database attached as schema
volatile(§3.3).
The volatile database's own journal mode is memory, not WAL. The
persistent side therefore has multi-version concurrency — readers see a
consistent snapshot and do not block writers — and the volatile side does
not. §4.4 covers what follows from that.
The volatile tables are created only on the write connection. Read and snapshot connections attach the same shared-cache URI and see the tables through it.
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. They do not
consult the request's transaction id, so they neither join a caller's
transaction nor decline a read-only one, and they open and commit work of
their own.
4.2 Request Dispatch
Peios / Advanced Peios / 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 / Advanced Peios / 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.
If the mode field is absent from the request, the transaction is 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.
Because the connection has both the hive database and the volatile database attached, a single SQLite transaction spans both. Persistent and volatile mutations made inside one transaction commit together and roll back together, with no separate mechanism reconciling them.
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.
The snapshot is exact for persistent data. Volatile data has no snapshot mechanism: a volatile read inside a read-only transaction observes the live store.
A mutating operation carrying a read-only transaction id is rejected with
RSI_INVALID before any state changes — for the nine operations that
route through the write path. RSI_DELETE_LAYER and RSI_FLUSH do not
check (§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. Rollback
errors are logged and not reported. This is how the kernel releases a
read-only snapshot after a REG_IOC_BACKUP finishes or fails.
A transaction that is neither committed nor aborted is never cleaned up: there is no timeout and no reaper. It holds its hive's write connection until the process exits — see §4.4.
4.4 Waiting and Contention
Peios / Advanced Peios / loregd / Concurrency
Every connection is opened with busy_timeout set to 25 seconds,
deliberately shorter than the kernel's 30-second request timeout so that
loregd can answer RSI_TXN_BUSY before the caller is timed out from
above.
That bound governs one kind of waiting: contention for SQLite's own write lock on the hive database. Two other kinds arise, and neither is bounded by it. loregd issues no database operation with a deadline attached.
4.4.1 Waiting for the write connection #
Each hive's write handle owns exactly one connection (§4.1). When a
read-write transaction binds, it holds that connection until it commits or
aborts, so any other write to the same hive waits — and it waits inside
Go's connection pool, before SQLite is ever reached. busy_timeout is not
consulted, because there is no SQLite lock in contention; the second
writer simply has no connection to run on.
RSI_FLUSH is the one operation that refuses to join this queue. It
checks whether any transaction is bound to the hive and returns
RSI_TXN_BUSY immediately if one is, because a checkpoint on a connection
already held by a transaction would deadlock. The check is racy — a
transaction can bind between the check and the checkpoint. Note also that
it does not distinguish a read-only snapshot, which lives on its own
connection, from a write binding, so a flush during a backup returns
RSI_TXN_BUSY even though the checkpoint could have proceeded.
RSI_DELETE_LAYER, a non-transactional RSI_DROP_KEY, and the
conditional-write path of a non-transactional RSI_SET_VALUE all take the
write connection without that guard, and queue behind a bound transaction.
4.4.2 Waiting on the volatile store #
The volatile database is in shared-cache mode with journal mode memory
(§4.1). It therefore has no multi-version concurrency: readers and writers
contend for table locks rather than passing each other.
Contention confined to the persistent side behaves as WAL promises: a transaction writing only the hive database does not block reads of it, and a transaction writing only volatile tables does not block reads of the hive database.
4.4.3 Abandoned transactions #
Nothing reclaims a transaction that is never committed or aborted. There is no timeout, and no sweep at any point in the daemon's life. Such a transaction holds its hive's write connection — and, if it wrote volatile data, its volatile table locks — until the process exits.
5.1 Store Routing
Peios / Advanced Peios / 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 an operation that names a single key, the key's own volatile column
selects the store. loregd reads it with one statement across both schemas:
SELECT volatile FROM main.keys WHERE guid = ?
UNION ALL
SELECT volatile FROM volatile.keys WHERE guid = ?
LIMIT 1
A GUID present in neither produces RSI_NOT_FOUND for most operations.
Note that routing follows the column value, not which table the row
came from. Rows loregd writes are always consistent about this — a row in
volatile.keys carries volatile = 1, a row in main.keys carries 0 —
so the distinction only matters if a database were modified externally.
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 on the volatile flag of 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 / Advanced Peios / 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:
| Response | Sorted by |
|---|---|
RSI_LOOKUP path entries | layer, then sequence |
RSI_ENUM_CHILDREN children | folded child name |
RSI_ENUM_CHILDREN per-child entries | layer, then sequence |
RSI_QUERY_VALUES value entries | folded value name, then layer, then sequence |
| Key-metadata blocks, in any response | ascending GUID, compared bytewise |
This ordering is a wire-stability guarantee only. It has no bearing on layer resolution, which is order-independent — the kernel selects a maximum, not a first match.
5.2.1 Arrays that are not sorted #
Two arrays reach the wire in the order the query produced them:
- The blanket-tombstone array in an
RSI_QUERY_VALUESresponse. It comes from an unorderedUNION ALLlike everything else, but is emitted unsorted. - The orphan-GUID array in an
RSI_DELETE_LAYERresponse. That operation walks every registered hive and concatenates their orphan sets, and the walk follows Go's randomised map iteration, so the array order differs between otherwise identical calls.
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
whichever row the unordered union yielded first. The order of children
is stable, because it is sorted on the folded name; the case of the name
reported for such a child is not.
5.3 Key Operations
Peios / Advanced Peios / 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. Because that key is per-schema, a GUID already
present in the other store does not collide: creating a persistent key
whose GUID exists in volatile.keys succeeds, and the GUID then exists in
both. Subsequent metadata reads resolve such a GUID to the main row,
since the reading query takes the first row of a UNION ALL that puts
main first.
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:
| Bit | Value | Field |
|---|---|---|
| 0 | 0x01 | sd |
| 1 | 0x02 | last_write_time |
Valid masks are therefore 0x00, 0x01, 0x02 and 0x03. Any other bit
set returns RSI_INVALID — it indicates an attempt to modify an immutable
field.
loregd builds one UPDATE from the mask, setting only the named fields:
-- mask 0x03
UPDATE keys SET sd = ?, last_write_time = ? WHERE guid = ?
A mask of 0x00 names no fields and acts as an existence check, returning
RSI_OK or RSI_NOT_FOUND. An update that matches no row also returns
RSI_NOT_FOUND.
Outside a transaction the update is a single auto-committed statement. Inside one it runs on the transaction's connection.
5.3.4 RSI_DROP_KEY #
Purges every trace of a GUID from both stores — four tables in each schema:
DELETE FROM keys WHERE guid = ?;
DELETE FROM path_entries WHERE target_guid = ?;
DELETE FROM [values] WHERE key_guid = ?;
DELETE FROM blanket_tombstones WHERE key_guid = ?;
Outside a transaction, the eight statements are wrapped in a
BEGIN IMMEDIATE transaction of their own so the purge is atomic. Inside
one, they run on the transaction's connection.
Dropping a GUID that does not exist returns RSI_OK; so does one that
resolves to no hive. The operation is idempotent. The GUID is evicted from
the hive cache (§4.2).
5.4 Path Entry Operations
Peios / Advanced Peios / 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, the
metadata fetch finds nothing and the whole request fails with
RSI_STORAGE_ERROR. 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.
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 child key's volatile flag (§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). Since that key is per-schema, an
identical entry in the other store does not collide, and the same triple
can come to exist in both — in which case both rows are returned by lookups
and enumerations (§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
parent key's volatile flag, 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, and the same RSI_STORAGE_ERROR arises for an entry whose
key record does not yet exist.
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 / Advanced Peios / 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 are sorted; blanket tombstones are not (§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 key's volatile flag selects the store; 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 key's volatile flag selects the store, and 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 / Advanced Peios / loregd / Request Handling
Neither operation in this section consults the request's transaction id (§4.1). Both take the hive's write connection directly and commit work of their own.
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's order is not stable across calls (§5.2).
Every failure is reported as RSI_STORAGE_ERROR; busy errors are not
classified separately, unlike the other write paths.
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 / Advanced Peios / loregd / Request Handling
loregd returns seven of the RSI status codes:
| Status | Value | Returned when |
|---|---|---|
RSI_OK | 0 | The operation succeeded. Also returned by the idempotent deletions when their target was already absent. |
RSI_NOT_FOUND | 1 | A named key GUID exists in neither store, or resolves to no registered hive. Also an update matching no row. |
RSI_ALREADY_EXISTS | 2 | A primary-key collision in the target table — a duplicate key GUID, path entry, or value entry within one schema. |
RSI_STORAGE_ERROR | 3 | A SQLite failure while serving the request. Also an operation whose GUID belongs to a hive other than the one its transaction is bound to, a mutating operation carrying an unknown transaction id, a failed commit that was not busy, and any RSI_DELETE_LAYER failure. |
RSI_TXN_BUSY | 6 | BEGIN IMMEDIATE found the database busy, a conditional write could not begin its transaction, or RSI_FLUSH found a transaction bound to the hive. |
RSI_INVALID | 7 | An unknown opcode, a request whose payload cannot be decoded, an out-of-range RSI_WRITE_KEY field mask, a mutating operation carrying a read-only transaction id, an RSI_FLUSH naming an unregistered hive, or an RSI_BEGIN_TRANSACTION re-using an active id. |
RSI_CAS_FAILED | 8 | A conditional RSI_SET_VALUE whose target was absent or whose sequence did not match. |
Three codes are defined by the interface and never produced by loregd:
RSI_NOT_EMPTY (4), RSI_TOO_LARGE (5), and RSI_TXN_NOT_SUPPORTED (9).
RSI_TXN_NOT_SUPPORTED is never needed because loregd supports both
transaction modes (§4.3).
RSI_TOO_LARGE is never returned because an oversized or malformed frame
is not answered at all. Framing is validated before an opcode is known, and
a frame that fails validation ends the connection instead of producing a
response (§2.3).
Appendix A Prior Art
Peios / Advanced Peios / 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, in-memory 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 / Advanced Peios / 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 a synthetic 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).
Two subsystems adapt to the workload rather than being tuned for it. Adaptive indexing watches which fields queries filter on and maintains indexes for them, shedding those indexes under write pressure because throughput outranks query latency (§3.4). Adaptive rollups pre-compute the metric aggregations that are asked for often (§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 / Advanced Peios / 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 / Advanced Peios / 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, kacs_open_peer_token, 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, opened read-only and still queried (§3.3).
Handoff channel: the bounded queue between drain threads and a writer thread. It is a staging area for the current batch, not a buffer (§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 a
synthetic.-prefixed type (§2.6).
Gap record: the 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 pre-computed metric aggregate for one series, one function and one time window (§5.6). Rollup registry: the global set of (function, window) pairs being pre-computed.
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 / Advanced Peios / 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 / Advanced Peios / 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 is bounded by the maximum batch size and nothing else accumulates.
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 / Advanced Peios / eventd / Event Ingestion
2.2.1 Attachment #
At startup eventd discovers the CPU count by calling kmes_attach with
incrementing CPU identifiers from 0 until the call returns EINVAL.
Each successful call returns one file descriptor for that CPU's ring
buffer, and eventd maps each one. The mapping size is derived from the
capacity value the call reports, as PSPK defines it.
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 copies the event — header and payload — into
process-local memory before it advances read_pos.
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 processed.
- Calls
kmes_attachagain for its CPU, obtaining a descriptor for the resized buffer. - Maps the new buffer.
- Unmaps the old one and closes the old descriptor.
- Scans the new buffer for the first event whose sequence number exceeds the recorded one.
- Resumes draining from there.
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.
The scan in step 5 is what makes the transition lossless in both directions: no event is skipped, and none is written twice.
2.2.5 Sequence tracking #
Each drain thread holds the last sequence number it saw for its CPU. It serves gap detection (§2.5) and resumption after a generation change or a restart.
At startup, if committed rows already exist for the current boot, the resume point for each CPU is derived from those rows:
MAX(sequence)
WHERE boot_id = current_boot_id
AND cpu_id = cpu
AND sequence IS NOT NULL
across every readable event shard database, historical shards included. Historical shards can hold current-boot rows: an eventd restarted within one boot under a smaller shard count leaves its higher-numbered shards behind, and the events it wrote to them before the restart are still that boot's events.
The sequence IS NOT NULL clause excludes synthetic events, which have
no sequence numbers (§2.6).
Committed rows are the authority. The metadata database holds sequence checkpoints and the shutdown event records the same numbers (§3.5, §8.4), but both are diagnostic: a checkpoint can be stale in exactly the case that matters, where eventd died between its last commit and its last checkpoint, and trusting it would mean re-reading events already stored or skipping a gap.
With no prior rows for the current boot, the tracker starts at 0. The first event on each CPU carries sequence number 1, so a tracker at 0 expects 1 and detects a gap correctly if the first event it sees is later.
2.3 Sharding
Peios / Advanced Peios / eventd / Event Ingestion
2.3.1 The shard count #
Event writes are distributed across one to 256 independent SQLite
databases. The count comes from StorageShards (§A); zero means "as
many shards as there are CPUs", and is the default.
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 CPU count distributes shards evenly across CPUs. 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.
For each CPU c, eventd assigns every shard j where
j % cpu_count == c. If that produces nothing — which happens when
there are fewer shards than CPUs — CPU c is instead assigned
c % shard_count. Every CPU ends with at least one write path.
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 its events round-robin, sending each successive event to the next shard it owns.
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 cpu_id 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 §5.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 does not exceed the maximum batch size (§2.4).
When the channel is full the drain thread stops reading from the ring buffer and waits. It does not drop events to relieve the pressure and it 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 commits and the channel has room, the drain thread resumes immediately.
The channel is a staging area for one batch, not a second buffer. Its bound is the batch size precisely so that it cannot become one.
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 / Advanced Peios / 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.
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:
- no assigned drain thread currently has an event available
- the batch holds
MaxBatchSizeevents MaxBatchLatencyMshas elapsed since the first event entered it
- 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.
2.5 Gap Detection
Peios / Advanced Peios / 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.
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 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
- the timestamp of the event that revealed the gap
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. 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), 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 cpu_id == 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 out of the resume-point derivation in §2.2.
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 / Advanced Peios / 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 wall-clock timestamp taken when eventd
generated the record, and an event type string prefixed synthetic.
which is what distinguishes them in the events table — no separate
record-type column exists (§3.1).
2.6.1 When they are generated #
| Condition | Type |
|---|---|
| Lost events detected on a CPU | synthetic.gap (§2.5) |
| eventd started and attached to KMES | synthetic.startup |
| Graceful shutdown beginning | synthetic.shutdown |
| A write to any store failed | synthetic.storage_error |
| A configuration value changed at runtime | synthetic.config_change |
Payload schemas for all five are in §3.2.
Note what is absent: there is no synthetic event for malformed ingestion input. Log and metric datagrams arrive unauthenticated from arbitrary local processes, and emitting a durable record per bad datagram would hand every process 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 — startup, shutdown, configuration changes, storage errors — 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 storage error 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
Machine\System\eventd\Security\Events\synthetic governs them.
They are ordered by their eventd-assigned timestamp and take no part in per-CPU sequence numbering.
That timestamp is when eventd noticed, not when the condition occurred. A gap record is stamped at detection, which may be long after the events it describes were overwritten — and after a restart, may be the first thing written in a new boot about events lost in the previous one.
3.1 The Events Table
Peios / Advanced Peios / eventd / Event Storage
Every shard database holds one events table.
| Column | Type | Contents |
|---|---|---|
id | INTEGER PRIMARY KEY | SQLite rowid, monotonic within the shard. |
boot_id | BLOB NOT NULL | 16-byte boot ID GUID in PCDS binary layout. |
timestamp | INTEGER NOT NULL | Nanoseconds since the Unix epoch. From the KMES header for a real event; eventd's clock at generation for a synthetic one. |
cpu_id | INTEGER | From the KMES header. Null for daemon-wide synthetic events; populated for gap records (§2.5). |
sequence | INTEGER | Per-CPU, per-boot sequence from the KMES header. Null for every synthetic event. |
origin_class | INTEGER | 0 userspace, 1 KMES, 2 KACS, 3 LCS. From the header. Null for synthetic events. |
event_type | TEXT NOT NULL | From the header; or a synthetic.-prefixed string. |
effective_token_guid | BLOB | 16-byte GUID for the effective token at emission. Null for synthetic events; the null GUID when identity was unavailable at emission. |
true_token_guid | BLOB | 16-byte GUID for the process's primary token. Null for synthetic events. |
process_guid | BLOB | 16-byte GUID for the emitting process. Null for synthetic events. |
payload | BLOB | MessagePack. For a KMES event, the raw payload bytes exactly as received. For a synthetic event, a map (§3.2). Null when the event carries none. |
3.1.1 Header fields are columns #
Every KMES header field is extracted into its own column rather than
left inside the payload blob. That is what lets a predicate on
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).
event_type is the sole discriminator between real and synthetic
records. No record-type column exists, because the synthetic. prefix
already partitions the type namespace and a second column would be a
second thing to keep consistent.
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 field that collides with a header name recoverable: the value is suppressed from the query surface but remains in the blob.
3.1.3 Identity may be absent two ways #
effective_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.4 Write-time indexes #
One index is created with the table:
idx_events_timestamponevents(timestamp)
Time-range filtering is the foundational access pattern — nearly every
query carries a SINCE — and it is the one index eventd never sheds,
whatever the write pressure (§3.4). Every other index is the adaptive
system's business.
3.1.5 Schema version #
Each shard holds a metadata table:
| Column | Type | Contents |
|---|---|---|
key | TEXT PRIMARY KEY | Metadata key. |
value | TEXT NOT NULL | Metadata value. |
with two required entries: schema_version, and created_at as a UTC
timestamp formatted YYYY-MM-DDTHH:MM:SSZ. The current version is in
§B.
eventd checks the version at startup and applies the lifecycle rules of §3.3. It does not migrate: an unrecognised version is a startup failure for an active shard and an exclusion for a historical one. Migration is an administrative operation, deliberately not an automatic one — a daemon that silently rewrote an audit store's schema on first start after an upgrade would be doing the one thing an audit store must not do unattended.
3.2 Synthetic Event Payloads
Peios / Advanced Peios / eventd / Event Storage
Each of the five synthetic event types (§2.6) carries a MessagePack map
in payload, with the schema below. These field names are stable
query-language payload field names after flattening (PSPU §3.22), except
where a value is a nested array or map, which flattening does not
traverse.
3.2.1 synthetic.startup #
| Field | Type | Contents |
|---|---|---|
boot_id | string | The current boot ID, PCDS canonical GUID form. |
restart | bool | True when committed rows for this boot already existed at startup; false on the boot's first eventd start. |
shard_count | unsigned integer | Active shard count after resolving StorageShards. |
resume_points | array of map | One entry per CPU, ordered by cpu_id ascending. Each has cpu_id and sequence, both unsigned integers. |
restart 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 synthetic.shutdown #
| Field | Type | Contents |
|---|---|---|
last_sequences | array of map | One entry per CPU, ordered by cpu_id ascending. Each has cpu_id and sequence — the last committed sequence for that CPU this boot, or 0 if none was. |
Diagnostic only. Startup derives its resume points from committed rows, never from this payload (§2.2).
3.2.3 synthetic.gap #
| Field | Type | Contents |
|---|---|---|
cpu_id | unsigned integer | Where the gap was detected. |
first_sequence | unsigned integer | First missing sequence number. |
last_sequence | unsigned integer | Last missing sequence number. |
count | unsigned integer | How many are missing. |
last_seen_timestamp | timestamp or nil | The last event successfully processed before the gap, when known. |
revealing_timestamp | timestamp | The event or ring position that revealed the gap. |
cpu_id appears both here and in the cpu_id column (§2.5). The column
is what a WHERE cpu_id == N predicate matches; the payload field is
what a reader of the record sees without joining anything.
3.2.4 synthetic.config_change #
| Field | Type | Contents |
|---|---|---|
key | string | The key name, relative to Machine\System\eventd\. |
old_value_type | string | absent, REG_SZ, REG_DWORD, REG_QWORD or REG_BINARY. |
old_value | string or nil | The previous value rendered as below; nil when the type is absent. |
new_value_type | string | The same five. |
new_value | string or nil | The new value; nil when absent. |
Values are rendered deterministically so that two eventd instances
observing the same change record the same bytes: REG_SZ as the string
in UTF-8, REG_DWORD and REG_QWORD as unsigned decimal without
leading zeroes, REG_BINARY as lowercase hexadecimal, two digits per
byte.
Everything is a string, including numbers, because the field is the same
field for all five types and a query filtering WHERE key == "…" should
not have to know which.
3.2.5 synthetic.storage_error #
| Field | Type | Contents |
|---|---|---|
store | string | event, log, metric or metadata. |
shard_index | unsigned integer or nil | The shard for event-store errors; nil for the other three. |
error | string | Human-readable description. |
error is diagnostic text and its wording is not stable. store and
shard_index are the fields worth alerting on.
3.3 Database Lifecycle
Peios / Advanced Peios / 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.
eventd creates the directory if it is absent.
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 is created with:
- WAL mode,
PRAGMA journal_mode=WAL PRAGMA synchronous=FULL- the
eventsandmetadatatables (§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).
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 a synthetic.storage_error 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, 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 / Advanced Peios / 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 it is the only writer to
the desired set.
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. When a writer thread has no pending events 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.
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. event_type, origin_class, cpu_id,
effective_token_guid, true_token_guid, process_guid, boot_id.
timestamp is always indexed and is not adaptively managed.
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 top-level key colliding with a header field, a key that is not a valid segment, a duplicate path — never receives one, because it is not a query-language field at all.
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> —
idx_events_event_type, idx_events_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 / Advanced Peios / 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 — the adaptive index and rollup state, diagnostic
sequence checkpoints, and the administrative Security Descriptor — and
it is the one database that survives shard reconfiguration untouched.
It is created on first startup if absent, opened in WAL mode with
synchronous=NORMAL. It is written once per policy interval and read at
startup, so per-transaction durability buys nothing: losing the last
interval's counters 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).
| Column | Type | Contents |
|---|---|---|
field_path | TEXT PRIMARY KEY | Field name or payload path: event_type, granted_access, source.name. |
query_count | INTEGER NOT NULL | Queries filtering on it within the current window. |
window_start | INTEGER NOT NULL | When the window started, nanoseconds since the epoch. |
desired_indexes — the computed desired index set.
| Column | Type | Contents |
|---|---|---|
field_path | TEXT PRIMARY KEY | Field name or payload path. |
priority | INTEGER NOT NULL | Rank; lower is higher priority. |
is_expression | INTEGER NOT NULL | 1 for a payload expression index, 0 for a column index. |
rollup_counters and desired_rollups — the same pair for
metric rollups (§5.6), keyed by function_window, a composite of
function name and window size such as avg_3600.
| Column | Type | Contents |
|---|---|---|
function_window | TEXT PRIMARY KEY | Function and window, e.g. avg_3600. |
query_count | INTEGER NOT NULL | Queries using the pair within the window. |
window_start | INTEGER NOT NULL | When the window started. |
desired_rollups carries function_window and priority.
sequence_checkpoints — diagnostic only.
| Column | Type | Contents |
|---|---|---|
boot_id | BLOB NOT NULL | The boot the checkpoint applies to. |
cpu_id | INTEGER NOT NULL | CPU identifier. |
sequence | INTEGER NOT NULL | Last committed sequence for that pair when written. |
updated_at | INTEGER NOT NULL | When it was written. |
Primary key (boot_id, cpu_id). Startup resumption derives its points
from committed event rows and never from this table (§2.2). The table
exists so that an operator can see what eventd believed at shutdown and
compare it with what the rows say — the two disagreeing is itself
diagnostic.
meta — key-value.
| Column | Type | Contents |
|---|---|---|
key | TEXT PRIMARY KEY | Metadata key. |
value | BLOB NOT NULL | Strings as UTF-8 bytes, binary as raw bytes. |
with three required entries: schema_version (§B), created_at as a
UTC YYYY-MM-DDTHH:MM:SSZ string, and admin_sd, a self-relative
Security Descriptor governing administrative operations — the INDEX
command above all (§7.2).
The default admin_sd grants SYSTEM and Administrators.
3.5.2 Concurrency #
One writer connection, owned by the index and rollup 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 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, some diagnostics, and a descriptor with a known default — all of it reconstructible, none of it irreplaceable. Losing it costs the adaptation eventd had accumulated, and the counters begin refilling immediately.
The one thing recreation does lose is a customised admin_sd, which
reverts to the default.
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_countersandrollup_countersinto memory. - Load
desired_indexesanddesired_rollupsinto 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 / Advanced Peios / 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 v0.23 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 #
Retention runs on a background thread of its own, never on a writer or
drain thread, using a separate read-write connection per store. It
processes the event store first, then the log store (§4.4), then the
metric store (§5.5). The interval is RetentionCheckIntervalMinutes
(§A).
WAL mode lets a reader run alongside a writer, but not a second writer, so the retention thread coordinates with each shard's writer thread — a shard-level mutex taken before writing, which the writer briefly yields to.
Deletion is batched. Each transaction deletes at most
RetentionDeleteBatchRows and commits before the next, and between
batches the retention thread releases the coordination primitive and
rechecks writer pressure. A single unbatched DELETE over a month of
events would hold a write transaction for as long as it took, blocking
the writer thread and, behind it, the drain thread and the ring buffer.
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 / Advanced Peios / 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. peinit assigns it at each boot and eventd reads it at
startup.
Derived metric rollups are the exception. They are boot-agnostic
aggregates and carry no boot_id (§5.6).
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). Rollups stay boot-agnostic scalars, so a boot-filtered metric query is served from raw samples and never from a rollup.
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 current boot ID from peinit, then searches every readable event shard database — historical shards included — for committed rows carrying it.
No committed rows for this boot. This is the boot's first eventd
start. eventd resets every per-CPU sequence tracker to 0, records the
new boot ID for all subsequent writes to all three stores, and emits
synthetic.startup with restart false.
Committed rows exist. eventd crashed and peinit restarted it within
the same boot. eventd restores each CPU's tracker from the maximum
non-null sequence for (boot_id, cpu_id) across every readable shard,
with CPUs having no rows resuming at 0; continues writing under the
existing boot ID; and emits synthetic.startup with restart 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 rows are the authority throughout. The metadata database's
sequence checkpoints and the previous synthetic.shutdown payload
record the same numbers, and both are diagnostic — neither is consulted
for resumption (§2.2, §3.5).
4.1 The Log Writer
Peios / Advanced Peios / 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, after which the kernel discards them. The
queue — SO_RCVBUF — is sized at four times the datagram ceiling, and it
is the whole cushion.
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.
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.2 The Logs Table
Peios / Advanced Peios / 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 one logs table.
| Column | Type | Contents |
|---|---|---|
id | INTEGER PRIMARY KEY | SQLite rowid, monotonic. |
boot_id | BLOB NOT NULL | 16-byte boot ID GUID in PCDS binary layout. |
timestamp | INTEGER NOT NULL | Nanoseconds since the Unix epoch — the producer's value if it supplied one, otherwise eventd's clock at receipt. |
origin | TEXT NOT NULL | The producing program's name, as the producer declared it. |
is_error | INTEGER NOT NULL | 1 for standard error or an explicitly marked error, 0 otherwise. |
message | TEXT NOT NULL | The log text. |
job_id | BLOB | 16-byte correlation GUID when the producer supplied one; null otherwise. |
The schema is deliberately narrow. A log record is text with light
metadata: what produced it, whether it was an error, when, and
optionally which execution it belongs to. There is no payload blob and
no origin class, and the only identity-like field is the optional
correlation key — which is not an identity at all, since origin is
self-asserted and unverified (PSPU §3.28).
A program needing more structure than this emits events.
4.2.1 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.2 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 directly-submitted lines 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.3 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.4 Schema version #
The log store holds a metadata table with the same two-column
structure as a shard's (§3.1). Its version 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 / Advanced Peios / eventd / Log Storage
4.3.1 Path #
The log store is the file named by LogStorePath (§A) — a file path,
unlike the event store's directory. There is no compiled-in default: a
missing or invalid value is a startup failure.
eventd creates the file and any absent parent directories.
4.3.2 Creation #
A log store that does not exist is created with:
- WAL mode,
PRAGMA journal_mode=WAL PRAGMA synchronous=NORMAL- the
logsandmetadatatables (§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.
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 #
One read-write connection owned by the log writer thread, and any number of read-only connections owned by query handlers. WAL mode lets them run concurrently.
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 / Advanced Peios / 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 v0.23 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 #
Deletion is batched at RetentionDeleteBatchRows per transaction, with
a commit between batches. Between them the retention thread releases any
writer coordination primitive and rechecks writer pressure.
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 / Advanced Peios / 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.
5.1.1 Processing a record #
For each valid record:
- 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 silently. 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.
Step 2 is the failure that leaves no trace. A producer that changes a metric's type has silently stopped emitting it — every sample discarded, no event, no counter, nothing in any log — and the only symptom is a series that stopped advancing. The reason nothing is emitted is PSPU §3.4: ingestion is unauthenticated, and reacting to input at all is an amplification vector.
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 — which is what makes rollup
computation and RATE evaluation deterministic regardless of arrival
(§5.6, §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 / Advanced Peios / 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 #
| Column | Type | Contents |
|---|---|---|
id | INTEGER PRIMARY KEY | Series identifier; the foreign key samples uses. |
name | TEXT NOT NULL | The metric name. |
labels | TEXT NOT NULL | Canonical label representation. Empty string for no labels. |
type | INTEGER NOT NULL | 0 counter, 1 gauge, 2 histogram. |
label_hash | INTEGER NOT NULL | Hash of the canonical label string. |
boundaries_hash | INTEGER | Hash of the canonical boundary blob. Null for counters and gauges. |
boundaries | BLOB | Canonical boundary blob. Null for counters and gauges. |
5.2.1.1 The canonical label string #
Labels are sorted by key in unsigned UTF-8 byte order, each pair written
key=value, and the pairs joined with commas: core=0,host=server1.
The empty label set is the empty string.
No escaping is performed and none is needed, because ingestion rejects
= and , inside a key or a value (PSPU §3.10). That prohibition
exists precisely to make this encoding unambiguous, and it is the reason
the constraint binds the producer rather than being handled internally.
5.2.1.2 The boundaries blob #
A fixed binary encoding, not MessagePack:
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_hash).
For counters and gauges boundaries_hash 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 #
| Column | Type | Contents |
|---|---|---|
id | INTEGER PRIMARY KEY | Internal row identifier; the tiebreaker for samples sharing a series and timestamp. |
series_id | INTEGER NOT NULL | References series(id). |
boot_id | BLOB NOT NULL | 16-byte boot ID GUID. |
timestamp | INTEGER NOT NULL | Nanoseconds since the Unix epoch. |
value | REAL NOT NULL | The raw value for counters and gauges. Stores 0 for histograms. |
histogram_data | BLOB | Canonical MessagePack histogram sample map. Null for counters and gauges. |
For a histogram, histogram_data is a canonical MessagePack map
(PSPU §3.5) with exactly four keys: boundaries, an array of float64
in the producer's order; counts, an array of unsigned integers;
total_count; and sum, a finite float64.
value is a placeholder for histogram rows and is never returned as a
metric query value. Storing 0 rather than null keeps the column
NOT NULL and keeps the row layout uniform.
Canonical encoding is required here because a stored sample map must be byte-stable: two equal histograms encode identically, which is what makes them comparable without decoding.
boot_id is per sample and is not part of the series identity, so a
series stays continuous across a reboot (§3.7).
5.2.3 Ordering #
Query execution order within a series is always (timestamp, id)
ascending, never insertion order alone. Duplicate timestamps are
permitted and id gives them a stable order.
id is internal. It is never exposed as a query field, a result field,
an access-control field, or a reserved label key (PSPU §3.28).
5.2.4 The rollups table #
The database also holds rollups, defined in §5.6. Rollups are
boot-agnostic scalar aggregates and carry no boot_id, which is why a
boot-filtered metric query is never served from one.
5.2.5 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_rollups_series_function_windowonrollups(series_id, function, window_seconds, window_start).
5.2.6 Schema version #
A metadata table with the same structure as the other stores' (§3.1).
Version 1 comprises series, samples, rollups and metadata; the
current value is in §B. eventd checks it at startup, applies the
lifecycle rules of §5.4, and does not migrate.
5.3 Series Resolution
Peios / Advanced Peios / 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 defend against this and cannot: at the interface, a producer creating a genuinely new series is indistinguishable from one creating garbage, and every available defence would break a correct producer to inconvenience an incorrect one (PSPU §3.13).
5.4 Database Lifecycle
Peios / Advanced Peios / eventd / Metric Storage
5.4.1 Path #
The file named by MetricStorePath (§A). No compiled-in default; a
missing or invalid value is a startup failure. eventd creates the file
and any absent parent directories.
5.4.2 Creation #
- 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. Missing or unrecognised is a startup failure; no migration.
- Verify structural integrity — required tables and indexes present,
including
rollupsand its lookup index. 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.
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 #
One read-write connection owned by the metric writer thread, and any number of read-only query connections. WAL mode permits both concurrently.
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.2).
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 / Advanced Peios / 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. - Track every
series_idwhose samples were deleted by either step. - Delete every
rollupsrow for those series. - Delete every
seriesrow with no remaining samples.
Logical live size is the same measure as §3.6.
Step 4 is not optional bookkeeping. A rollup is a pre-computed aggregate of raw samples, so a rollup outliving its inputs would be served to a query as an exact answer computed from data the store no longer has — and the query engine, finding a matching rollup, would never look at the raw samples to notice (§5.6). Rollups for the affected series can be recomputed later from whatever samples remain.
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 ages out —
ninety days by default — so a burst of short-lived series from a
high-cardinality producer stays in the table for that long.
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.
5.5.3 Not yet: downsampling #
The v0.23 model deletes; it does not downsample. A later retention
engine is expected to aggregate high-resolution data into
lower-resolution rollups as it ages — per-second samples becoming
five-minute averages after a week, hourly averages after a month —
which is what makes long-term metric retention affordable while
preserving the trend. The rollups table is the mechanism that would
serve it, but nothing currently promotes raw samples into rollups as a
retention action.
5.5.4 Batching and reclamation #
Batched at RetentionDeleteBatchRows per transaction, with the
coordination primitive released and writer pressure rechecked between
batches — the same rule and the same reason as §4.4, since the metric
writer is also the thread draining the metric socket.
VACUUM is never run automatically. Freed pages are recycled and are
excluded from the size measure.
5.6 Adaptive Rollups
Peios / Advanced Peios / eventd / Metric Storage
Aggregate metric queries read raw samples. Over a large range that means scanning thousands or millions of rows to produce a handful of numbers, and the same numbers over and over.
Rollups pre-compute them. The principle is adaptive indexing's (§3.4): watch which query patterns recur, compute their results in the background, and serve from the results when they exist. What differs is that a rollup is an answer rather than an access path, so it has to be exactly the answer the raw samples would have given.
5.6.1 The rollups table #
| Column | Type | Contents |
|---|---|---|
id | INTEGER PRIMARY KEY | SQLite rowid. |
series_id | INTEGER NOT NULL | References series(id). |
function | INTEGER NOT NULL | Function identifier (§B). |
window_seconds | INTEGER NOT NULL | Window size. |
window_start | INTEGER NOT NULL | Window start, nanoseconds since the epoch. |
value | REAL NOT NULL | The pre-computed value. |
sample_count | INTEGER NOT NULL | Scalar inputs that contributed. For AVG/MIN/MAX/SUM, raw samples; for RATE/DELTA, valid sample pairs. |
covered_ns | INTEGER NOT NULL | For RATE and DELTA, the elapsed nanoseconds the contributing pairs covered. Zero for AVG, MIN, MAX and SUM. |
A unique constraint and an index both cover
(series_id, function, window_seconds, window_start).
Every row satisfies: sample_count greater than zero, value finite,
and covered_ns greater than zero for RATE and DELTA and exactly zero
for the other four.
sample_count and covered_ns are what make rollups composable. A
window built from one sample is not as good as one built from sixty, and
combining sub-windows correctly needs their weights — an average
composes weighted by sample_count, a rate composes weighted by
covered_ns. Without them a rollup could only serve a query whose
window matched it exactly.
Rollups are per series and carry no boot_id. Cross-series
aggregation composes per-series rollup rows and then applies the query's
terminal aggregation across them.
5.6.2 What is not rolled up #
Percentiles. P50, P95 and P99 are not composable: the P95 of twelve five-minute P95 values is not the P95 of the hour. Percentile queries always compute from raw histogram samples.
A later revision could add histogram rollups storing merged bucket counts, computing percentiles from the rolled-up distribution — but that is a different storage model, not a row in this scalar table.
Histogram samples in scalar rollups. AVG, MIN, MAX and SUM roll up raw counter and gauge values only.
Non-window RATE and DELTA scalar aggregations. A stored RATE or DELTA row is a window-level rate, whereas a scalar aggregation over a transformed series operates on the per-pair values (PSPU §3.25). Serving one from the other would give a different answer, so these are not recorded and always fall back to raw samples.
5.6.3 The registry #
eventd maintains a global set of (function, window) pairs worth
pre-computing, derived from query frequency exactly as the desired index
set is (§3.4), with its counters in the metadata database (§3.5).
Each metric query with a rollup-eligible aggregation records a pair:
AVG_OVER,MIN_OVER,MAX_OVERandSUM_OVERrecord AVG, MIN, MAX and SUM respectively, when no RATE or DELTA transform is present, with the query's window duration.- With RATE or DELTA present alongside a window aggregation, the transform is the recorded function and the terminal aggregation is applied afterward to the per-series values. The window duration is again the query's.
- Scalar AVG, MIN, MAX and SUM over raw counter or gauge samples with a
SINCEclause record the same function usingAdaptiveRollupScalarWindowSeconds(§A) as the window — a scalar query has no window of its own, so a base window is chosen for it and composition covers the rest.
A pair crossing AdaptiveRollupCreateThreshold over the rolling window
joins the registry; one falling below AdaptiveRollupDropThreshold
leaves it. Both thresholds are lower than the indexing ones, because
rollup computation is cheaper — it proceeds window by window rather than
building a whole B-tree — and the speedup is larger, twenty-four rows
instead of eighty-six thousand for a daily query at one-second
resolution.
The registry is global: if hourly averages are queried often for anything, they are computed for every compatible series. Incompatible series are skipped.
5.6.4 Computation #
On a background thread, during low write activity. For each registry pair, the thread finds windows with raw samples but no rollup row, reads those samples, computes, and inserts.
Only completed windows. The current, still-accumulating window is never pre-computed and is always computed from raw samples at query time.
AVG, MIN, MAX and SUM take the raw counter or gauge values whose timestamps fall in the window. RATE and DELTA are computed for counter series only, and computation skips any series whose type the function does not fit.
RATE and DELTA use the same counter-window rule the query engine uses
(PSPU §3.25): consecutive pairs in (timestamp, id) order whose later
sample falls inside the window; the immediately preceding sample before
the first in-window one as the baseline for the first pair, where it
exists; pairs with non-positive elapsed time ignored. DELTA's value is
the sum of reset-adjusted deltas, and RATE's is that divided by
covered_ns in seconds. A window with no contributing inputs — or, for
RATE, zero covered_ns — gets no row at all rather than a zero.
Computation is cancellable on rising write pressure and resumes later, through the same mechanism as adaptive index creation (§3.4).
5.6.5 Serving a query from rollups #
When a metric query carries a rollup-eligible aggregation, the engine
checks for matching rollups. It uses them when a rollup covers the
requested function, window size and time range and the query does not
filter by boot_id — rollups are boot-agnostic, so a boot-filtered
query cannot be answered from one (§3.7).
Partial coverage is handled rather than refused. Where rollups exist for
the complete windows fully inside the effective range, the engine reads
those and computes the remaining prefix or suffix from raw samples. That
edge handling is required for exactness whenever a SINCE or UNTIL
bound is not aligned to the window.
With no matching rollup, or a boot filter, or a percentile, or a non-window RATE or DELTA scalar aggregation, the query falls back to raw samples entirely. The result is identical either way — rollups are a transparent optimisation and never a different answer.
5.6.6 Composition #
A rollup window need not match the query window for composable functions. Smaller windows serve larger queries; the reverse never works.
| Function | Composes by |
|---|---|
| AVG | weighted average by sample_count |
| MIN, MAX | min or max across sub-windows |
| SUM, DELTA | addition |
| RATE | sum(subrate × sub_covered_ns) / sum(sub_covered_ns) |
AVG_OVER 1h is served from twelve five-minute AVG rollups by weighting
each by its sample_count.
Counter resets are handled once, during computation: a stored RATE or DELTA already reflects reset-adjusted deltas, so composition operates on adjusted values and never has to consider a reset again.
For cross-series unbracketed window queries, composition happens per series first and the terminal aggregation is applied across series afterward. The weighting differs between two cases that look alike:
- Without a transform,
AVG_OVERacross series combines per-series AVG rollups weighted bysample_count, because the query means the average of every scalar sample value in the window. - With RATE or DELTA, the terminal average is an unweighted mean of the per-series window values, because under PSPU §3.25 each series contributes at most one scalar per window, and weighting one-value contributions by their pair counts would silently favour the busiest series.
5.6.7 Retention and departure #
Rollup rows follow raw sample retention. When retention deletes samples it deletes the rollups for the affected series (§5.5).
When a pair leaves the registry, existing rows are not deleted. They remain available to queries until they age out through normal retention; only new computation stops. Deleting them would discard work already done in exchange for nothing — the rows are correct, and a query that can use one still can.
5.6.8 Persistence #
The registry and its counters live in the metadata database (§3.5) and survive restarts. Existing rollup rows are discovered in the table itself; eventd resumes computation from whatever state it finds, exactly as it resumes index convergence (§3.4).
6.1 Parsing and Planning
Peios / Advanced Peios / 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
The identifier discovery step is the expensive one and its cost is not
bounded by the query. EVENTS SINCE 30d ago with no type pattern has to
establish every distinct event type in that range before it can
authorize anything, and whether that is an index scan or a table scan
depends on whether event_type currently has an index — which is an
adaptive decision that pressure may have reversed (§3.4).
6.1.4 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.5 Read connections #
Execution uses read-only SQLite connections, which in WAL mode do not block writer threads.
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 / Advanced Peios / 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:
| Mode | Appended, in order |
|---|---|
| Events | timestamp descending, shard index ascending, events.id descending |
| Logs | timestamp descending, logs.id descending |
| Metrics | timestamp ascending, metric name ascending, canonical labels ascending, and samples.id ascending where the row corresponds to a raw sample or a derived sample pair |
These are not query-language fields. They never appear in a result
record, cannot be named in a SORT or a SELECT, and have no
access-control identity (PSPU §3.28).
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, rollup 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 / Advanced Peios / 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 event_type,
process_guid or cpu_id becomes a SQL WHERE comparison over an
indexable column (§3.1).
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).
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 pushed into SQL wherever the storage engine can express it, which is most of the time for simple grouping over columns and none of the time for grouping over payload paths whose comparison semantics SQL cannot reproduce.
For an event query the push-down matters twice over, because it also determines what crosses the shard boundary (§6.4): a shard returning per-group partial aggregates sends a result proportional to the group cardinality, where a shard returning rows sends a result proportional to the row count.
6.4 Cross-Shard Fan-Out
Peios / Advanced Peios / 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 cpu_id scans all of them.
6.4.1 Merging #
How results combine depends on the query.
Non-aggregating queries. Each shard produces rows sorted by the effective sort key, tiebreakers included (§6.2), and the coordinator performs an N-way merge of the sorted streams.
With TAKE present, each shard returns at most SKIP + TAKE rows — or
TAKE rows when there is no SKIP — and the coordinator applies
SKIP and TAKE after merging. The total read is therefore at most
(SKIP + TAKE) × shard_count, which is the price of not knowing in
advance which shard holds the winning rows.
With TAKE absent, each shard streams every matching row until the
query completes or times out.
Aggregating queries. Each shard computes a partial aggregate and the coordinator combines them:
| Query | Shard returns | Coordinator does |
|---|---|---|
COUNT | its local count | sums |
COUNT BY, TOP N BY, GROUP … COUNT | per-group counts | sums per group key, sorts by count descending, applies TAKE |
GROUP … SUM | per-group sums | sums per group |
GROUP … AVG | per-group sum and count | computes the average from the combined pair |
GROUP … MIN / MAX | per-group min or max | takes the min or max |
DISTINCT | local distinct values | unions |
AVG is the one that cannot be composed from its own output. Averaging
per-shard averages weights each shard equally regardless of how many
rows it held, so a shard is asked for the sum and the count and the
coordinator divides once — the same reasoning that makes rollup
composition carry sample_count (§5.6).
Pushing aggregation down bounds the coordinator's memory to the group key cardinality times the shard count, rather than to the total row count.
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.
The query timeout is the only backstop (§6.5). Streaming merged results to the client incrementally, rather than materialising the whole set before sending, is what keeps the memory cost proportional to the merge frontier instead of to the result.
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 / Advanced Peios / 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 column reference increments that column's frequency counter
- 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.
Metric queries feed the parallel rollup registry counters instead
(§5.6), which record (function, window) pairs rather than fields.
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 rather than per-caller. eventd cannot attribute connections to a caller beyond the token it holds, so one client can occupy every slot — and the interim protection is that queries and ingestion are separate channels, so exhausting the query side cannot exhaust ingestion (PSPU §3.3).
6.5.3 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 / Advanced Peios / 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 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.3 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.4 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.5 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 / Advanced Peios / 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 Caller identity #
When a client connects to the query socket, eventd obtains its token by
calling kacs_open_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.
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.4 Rights #
| Right | Bit | Value | Meaning |
|---|---|---|---|
EVENTD_READ | 0 | 0x0001 | Read records matching the pattern. |
EVENTD_CLEAR | 1 | 0x0002 | Delete records matching the pattern. |
EVENTD_ADMINISTER | 2 | 0x0004 | Change eventd's own policy — the INDEX command (§7.2). |
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.5 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 / Advanced Peios / 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:
| Namespace | Patterns match |
|---|---|
| Events | event type |
| Logs | log origin |
| Metrics | metric name |
7.2.1 Matching #
A pattern matches by dot-delimited prefix. The pattern kacs matches
the exact string kacs and any string beginning kacs. — and matches
neither kacs_extended nor kacsfoo, because the dot is the hierarchy
separator and not a mere character.
* is the wildcard default and matches everything.
Ingestion constrains origins and metric names to the identifier grammar (PSPU §3.7, §3.10), which is what keeps a producer from choosing a name containing the wildcard or a registry path separator — 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.
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.access_denied. - Remove the last dot-separated component and look again,
kacs. - Repeat.
- 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.access_denied
Machine\System\eventd\Security\Logs\*
Machine\System\eventd\Security\Logs\loregd
Machine\System\eventd\Security\Metrics\*
Machine\System\eventd\Security\Metrics\cpu
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.4 Defaults on first boot #
eventd creates the three wildcard keys if they do not exist:
| Key | Default |
|---|---|
…\Security\Events\* | SYSTEM and Administrators: EVENTD_READ on all fields. |
…\Security\Logs\* | SYSTEM, Administrators and Authenticated Users: EVENTD_READ on all fields. |
…\Security\Metrics\* | SYSTEM, Administrators and Authenticated Users: EVENTD_READ on all fields. |
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. An administrator can tighten either.
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 admin_sd, held in the metadata
database rather than the registry (§3.5), 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.
7.3 Per-Field Control
Peios / Advanced Peios / 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 two-level tree with the data type's root at level 0 and one field per node at level 1.
Level 0: root GUID for the data type
Level 1: timestamp
Level 1: event_type
Level 1: cpu_id
Level 1: origin_class
Level 1: effective_token_guid
Level 1: true_token_guid
Level 1: process_guid
Level 1: granted_access
Level 1: target_sid
Level 1: source.name
kacs_access_check_list returns a verdict per node, and eventd uses
them to include or exclude each field.
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:
| Data | field_name |
|---|---|
| Event header field | the column name — timestamp, event_type, cpu_id |
| Event payload field | the flattened dot path — granted_access, target_sid, source.name |
| Log field | the column name — origin, message, is_error |
| Fixed metric field | timestamp, boot_id, name, type, value |
| Metric label | the label key — core, device |
Payload fields suppressed by flattening or by a header collision are not query-language fields, so they get no GUID (PSPU §3.22). 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. granted_access produces
the same GUID whatever event type carries it.
Scoping comes from the descriptor hierarchy: an object ACE naming the
granted_access GUID inside the descriptor for pattern kacs means
"the granted_access 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 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 timestamp, type and CPU:
- Allow SecurityAdmins,
EVENTD_READ, no object GUID - Allow MonitoringTeam,
EVENTD_READ, object GUID =timestamp - Allow MonitoringTeam,
EVENTD_READ, object GUID =event_type - Allow MonitoringTeam,
EVENTD_READ, object GUID =cpu_id
MonitoringTeam querying KACS events receives records containing exactly those three keys. Payload fields, identity GUIDs and the remaining header fields are absent.
7.3.5 Building the list per record #
The list is constructed from the fields actually present in the record being checked: the root node, then a level-1 node per field.
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.4 Enforcement
Peios / Advanced Peios / 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), build the object type list for the
fields the query references (§7.3), call
kacs_access_check_list, and cache the verdicts for this(token, identifier, field set)(§7.5). - Apply root verdicts. An identifier whose root is denied is invisible: its records are excluded from the logical row set before aggregation, ordering, pagination and formatting. 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. A denied root, 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 identifier, and cache the per-field results. - Shape each record. Look up the cached results for its identifier; exclude the record entirely if the root was denied; otherwise include it, and include each field only if its node was granted.
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 #
Every access check produces a KACS audit event through the SACL audit walk in the AccessCheck pipeline.
eventd passes an audit_context blob naming the security pattern being
accessed — "events:kacs.access_denied", "logs:loregd" — so the audit
trail records exactly which observability data was read, by whom, rather
than merely that eventd performed a check.
Those audit events are themselves KMES events, which eventd consumes and
stores, and which are governed by the synthetic-independent event
patterns like any other. Reading the audit store is auditable.
7.5 Caching
Peios / Advanced Peios / 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 #
When a pattern's descriptor contains no object ACEs, the check is a
plain grant or deny on the root, and the result is cached per
(token, pattern).
A query returning ten thousand events across twenty distinct event types performs at most twenty 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 is cached per
(token, pattern, field set).
In practice events of one type carry the same fields, so this is
effectively one check per (token, event type). Log records have a
fixed field set, so log queries reach one check per origin. 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. This is a degraded state, not a failure — eventd keeps ingesting, and keeps answering queries for descriptors already resolved (§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.6 The Write Path
Peios / Advanced Peios / eventd / Access Control
There is no per-record access control on the way in. This article records what stands in its place and what that leaves open.
7.6.1 Events #
Emission is KMES's business. kmes_emit and kmes_emit_batch require
SeAuditPrivilege, and eventd is not in the path — it consumes what KMES
delivers and applies no admission control of its own (§2.2).
The identity stamps on an event are the kernel's, captured from kernel
state at the moment of the write, and an emitting process cannot set,
influence or suppress them. That is what makes an event's process_guid
evidence in a way that a log's origin is not.
7.6.2 Logs and metrics #
The Security Descriptor on each ingestion socket is the entirety of the write-path control (PSPU §3.3). There is nothing per record: no token is obtained, no identity is checked, and no field is verified.
The socket descriptor is therefore doing all the work, and it is worth being precise about what it does and does not do on Peios. An access decision is routed through the object's Security Descriptor, not through POSIX mode bits, so setting a mode on a socket pathname restricts nothing — and an inode created without a descriptor is denied to every caller, so binding a socket into a directory carrying no inheritable ACEs produces a socket that nothing, including the service manager, can reach. eventd establishes the descriptor on each socket before it begins receiving on it.
7.6.3 Origin and name are claims #
origin in a log record and name in a metric record are self-reported
(PSPU §3.7, §3.10). Any process that can reach an ingestion socket can
write under any origin or metric name it likes, including one belonging
to another program.
The consequences are the obvious ones. A compromised service can inject log lines attributed to another service, manufacturing a plausible operational narrative. It can bury a real incident under noise attributed elsewhere. It can create metric series under a name a dashboard trusts.
Read-path descriptors limit who can see data written under a given
identifier; they do nothing about who wrote it. eventd never presents a
stored origin or metric name as evidence of provenance.
7.6.4 What this means for an operator #
The interim posture is that the ingestion sockets are a trust boundary that only separates "can reach eventd" from "cannot" — and on a system where every service logs, nearly everything is on the inside.
Two things follow. Data whose provenance must be trustworthy belongs in an event, where the kernel stamps the identity, rather than in a log where the producer asserts it. And read-path descriptors on the origins and metric names that matter are worth writing even so: they prevent an unauthorized reader from querying data written under a spoofed identifier, which is a smaller property than authenticity but not nothing.
8.1 Dependencies
Peios / Advanced Peios / eventd / Startup And Shutdown
eventd needs four subsystems before it can do anything.
| Subsystem | For |
|---|---|
| KMES | Event ingestion. Available as soon as PKM is loaded. |
| LCS and loregd | Configuration. eventd reads every setting from the registry. |
| KACS | Access control — the AccessCheck API for query authorization, and kacs_open_peer_token for caller identification. |
| peinit | The boot ID, and lifecycle management. |
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).
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 / Advanced Peios / eventd / Startup And Shutdown
Startup proceeds in seven phases, and either completes or fails entirely.
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. - 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 #
- Discover the CPU count by calling
kmes_attachwith incrementing CPU identifiers from 0 untilEINVAL. The call requires SeSecurityPrivilege in the effective token. Discovering no CPUs fails startup. - Map each per-CPU ring buffer.
- Resolve the active shard count from
StorageShards— the CPU 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 the log store — schema verified, WAL,
synchronous=NORMAL, quarantine on corruption (§4.3). - Open or create the metric store, likewise (§5.4). The series cache starts empty and fills on demand.
- Open or create
eventd-meta.db. Load the index and rollup counters and desired sets, 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 the current boot ID from peinit.
- Search every readable event shard for committed rows carrying it.
- No rows — the boot's first eventd start. Reset every per-CPU sequence tracker to 0 and record the new boot ID for subsequent writes.
- Rows exist — a restart within the boot. Derive each CPU's resume
point from the maximum committed
sequencefor(boot_id, cpu_id)across every readable shard; CPUs with no rows resume at 0 (§3.7).
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. - Create the log socket at
LogSocketPath, same rule. - Create the metric socket at
MetricSocketPath, same rule. - Establish 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.
8.2.6 Phase 6 — Threads #
- One drain thread per CPU, each beginning to read its ring buffer.
- One writer thread per active shard.
- The log ingestion thread.
- The metric ingestion thread.
- The retention thread.
- The adaptive indexing and rollup policy thread.
8.2.7 Phase 7 — Ready #
- Write and commit a
synthetic.startupevent recording the boot ID, the shard count and the per-CPU resume 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 / Advanced Peios / 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 and rollup thresholds and windows, the adaptive scalar rollup window, the WAL checkpoint threshold, the query timeout, the cross-type window and lookback limit, and the query and streaming concurrency limits.
8.3.2 What waits for a restart #
| Change | Why |
|---|---|
| Socket paths | The sockets are bound and clients are connected to them. |
| Store paths | The databases are open, and moving a store is a data migration, not a setting. |
StorageShards | Shard-to-CPU assignment, writer threads and handoff channels are all built from it at startup (§2.3). |
The watch notices these changes and eventd defers them rather than attempting a live migration. Shard count changes in particular are expected once in a machine's life, and rebalancing writer threads while events are in flight is a large mechanism for a rare event.
8.3.3 What is neither #
Security Descriptors under Security\ are not configuration in this
sense. The registry watch invalidates the descriptor cache and the next
query resolves afresh (§7.5), so a grant or revocation takes effect
immediately without anything being "applied".
8.3.4 Recording it #
eventd emits a synthetic.config_change event for every change applied
at runtime, carrying the key name and the old and new values rendered
deterministically (§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 / Advanced Peios / 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.
- Drain ingestion. Read and process the datagrams still in the log and metric receive queues, then close those descriptors. This is bounded by the queue size — four times the datagram ceiling — 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 the per-CPU last committed
sequence from committed rows — the same rule startup uses — and write
it to
sequence_checkpointsfor diagnostics (§3.5). - Emit the shutdown event. Write
synthetic.shutdownwith the per-CPU sequences, using the daemon-wide shard assignment rule (§2.6). 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.
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 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 resume points from committed rows and detects the difference between those rows and the ring buffer state as an ordinary gap (§2.5).
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 / Advanced Peios / 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 is a sequence gap. Events between the last committed batch and the crash were never persisted. On restart eventd derives its resume points from committed rows, sees the difference from the current ring buffer state, and writes a gap record (§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 what was missed. The boot boundary logic recognises the restart from the committed rows themselves rather than from any 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 #
| Signal | Behaviour |
|---|---|
SIGTERM | Begin graceful shutdown (§8.4). |
SIGINT | Begin graceful shutdown. |
SIGQUIT | Write a diagnostic dump to standard error, then begin graceful shutdown. |
SIGHUP | Re-read configuration from the registry (§8.3). |
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 last committed sequence numbers, derived from committed rows
- the current non-streaming and streaming query counts
- the metric series cache occupancy
- the last observed write error for each store, where one exists
The format is not a stable machine interface and its wording may change.
The set is chosen to answer the questions an operator has about a misbehaving eventd that a query cannot: how far behind the writers are, whether the series cache is thrashing (§5.3), whether query slots are exhausted (§6.5), and whether a store has been failing writes quietly. 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 the thing that is broken.
9.1 Losing Events
Peios / Advanced Peios / 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).
- A
synthetic.gaprecord is written, naming the missing range. - 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 discards the datagram. Neither the sender nor eventd is notified, and eventd does not count it.
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 / Advanced Peios / 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.
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
synthetic.gap records for every accumulated range before writing new
events.
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, resume points are derived from committed rows, and the difference from the current ring buffer state is detected as an ordinary restart gap (§3.7). The record is 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 synthetic.storage_error once a shard is available to hold it.
At write time, eventd stops writing to the affected database, emits
synthetic.storage_error 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 / Advanced Peios / 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:
kacs_open_peer_tokenfails 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 and metric ingestion are unaffected.
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 supplies the boot ID at startup and manages the lifecycle. It has no runtime role once eventd is running, so there is no failure mode here — peinit going away means the system is going away.
9.4 Resource Exhaustion
Peios / Advanced Peios / 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.
Three things bound eventd's memory, and each is worth knowing because each has a configuration that governs it:
| Consumer | Proportional to | Bounded by |
|---|---|---|
| Series cache | distinct metric series held in memory | MetricSeriesCacheSize (§5.3) |
| Index and rollup counters | distinct fields and function-window pairs queried | the query surface itself |
| SQLite page caches | connections and active indexes | per-connection configuration (§C) |
The handoff channels are deliberately not on that list. They are bounded by the batch size (§2.3), which is the entire point of the ring-buffers-are-the-only-buffer rule: the thing that would otherwise grow without limit under load is the thing that is fixed.
The consumer most likely to surprise is the series cache under a
high-cardinality producer — not because the cache grows, since it is
bounded, but because the series table does, and the cache then
thrashes on the thread that also drains the metric socket (§5.3).
9.4.2 Query slots #
Reaching MaxConcurrentQueries or MaxStreamingQueries rejects new
queries with an error rather than queueing them (§6.5). Both bounds are
global, so one client can occupy every slot.
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).
Retention holding a write lock. The shard's writer blocks until retention releases it. Retention works in small bounded batches and releases the coordination primitive between them, and under sustained write pressure it delays the next batch long enough for a waiting writer to make progress (§3.6). Events accumulate in the ring buffer during the stall, which is the ordinary absorption path.
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 / Advanced Peios / 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.
| Store | Setting | What survives |
|---|---|---|
| Event | synchronous=FULL | Every committed transaction. |
| Log | synchronous=NORMAL | Transactions up to the last checkpoint. |
| Metric | synchronous=NORMAL | Transactions up to the last checkpoint. |
The event store loses only the in-flight batch — the events accumulated since the last commit, which the adaptive batcher keeps as small as throughput allows (§2.4). On restart this appears as an ordinary sequence gap and is recorded as one (§3.7).
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 — derives its per-CPU resume points from the committed rows, and records the difference from the ring buffer state as a gap.
The one case that differs from a crash is that events emitted while the machine was off are gone from the ring buffers too, since those are memory. A gap after a power cut therefore covers the downtime as well as the uncommitted batch, and there is nothing anywhere that held those events.
Appendix A Configuration Keys
Peios / Advanced Peios / 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 a synthetic.config_change event
for every change actually applied (§8.3).
A.1 Required #
No compiled-in defaults. A missing or invalid value fails startup (§8.2).
| Key | Type | Description |
|---|---|---|
EventStorePath | REG_SZ | Directory for the event shard databases and eventd-meta.db. |
LogStorePath | REG_SZ | File path for the log store database. |
MetricStorePath | REG_SZ | File path for the metric store database. |
QuerySocketPath | REG_SZ | Unix socket path for queries. |
LogSocketPath | REG_SZ | Unix socket path for log ingestion. |
MetricSocketPath | REG_SZ | Unix socket path for metric ingestion. |
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 #
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
StorageShards | REG_DWORD | 0 | 0–256 | Number of event shards. 0 means the CPU count. |
MaxBatchSize | REG_DWORD | 10000 | 100–100000 | Maximum events per writer transaction. |
MaxBatchLatencyMs | REG_DWORD | 100 | 10–5000 | Maximum ms before an event batch commits. |
A.4 Log ingestion #
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
LogMaxBatchSize | REG_DWORD | 5000 | 100–100000 | Maximum log records per transaction. |
LogMaxBatchLatencyMs | REG_DWORD | 500 | 10–5000 | Maximum ms before a log batch commits. |
MaxLogDatagramBytes | REG_DWORD | 262144 | 4096–1048576 | Maximum accepted log datagram size. |
A.5 Metric ingestion #
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
MetricMaxBatchSize | REG_DWORD | 5000 | 100–100000 | Maximum metric samples per transaction. |
MetricMaxBatchLatencyMs | REG_DWORD | 1000 | 10–5000 | Maximum ms before a metric batch commits. |
MaxMetricDatagramBytes | REG_DWORD | 262144 | 4096–1048576 | Maximum accepted metric datagram size. |
MetricSeriesCacheSize | REG_DWORD | 50000 | 1000–1000000 | Entries in the LRU series resolution cache. |
A.6 Adaptive indexing #
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
AdaptiveIndexWindowHours | REG_DWORD | 24 | 1–168 | Rolling window over which query frequency is measured. |
AdaptiveIndexPolicyIntervalMinutes | REG_DWORD | 60 | 60–1440 | How often the desired index set is recomputed. The minimum of 60 prevents index churn. |
AdaptiveIndexCreateThreshold | REG_DWORD | 100 | 10–10000 | Queries on a field within the window needed to add it. |
AdaptiveIndexDropThreshold | REG_DWORD | 10 | 1–1000 | Queries below which it is removed. Less than the create threshold, which is what supplies the hysteresis. |
A.7 Index shedding #
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
SheddingWindowSeconds | REG_DWORD | 30 | 10–300 | Sliding window for graduated shedding. |
SheddingBatchPercent | REG_DWORD | 75 | 50–100 | Percentage of batches in the window exceeding 75% of MaxBatchSize that triggers graduated shedding. |
EmergencySheddingBufferPercent | REG_DWORD | 75 | 50–95 | Ring buffer fill percentage triggering emergency shedding. |
A.8 Adaptive rollups #
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
AdaptiveRollupWindowHours | REG_DWORD | 48 | 1–168 | Rolling window for rollup query frequency. |
AdaptiveRollupScalarWindowSeconds | REG_DWORD | 300 | 60–86400 | Base window used when a scalar range query triggers rollup creation. |
AdaptiveRollupCreateThreshold | REG_DWORD | 50 | 10–10000 | Queries needed to trigger rollup computation. |
AdaptiveRollupDropThreshold | REG_DWORD | 5 | 1–1000 | Frequency below which a pair leaves the registry. Less than the create threshold. |
A.9 Retention #
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
EventRetentionDays | REG_DWORD | 30 | 1–3650 | Maximum age of events. |
EventRetentionMaxBytes | REG_QWORD | 0 | 0–2^64−1 | Maximum total logical live size of the event shards. 0 means no limit. |
LogRetentionDays | REG_DWORD | 14 | 1–3650 | Maximum age of log entries. |
LogRetentionMaxBytes | REG_QWORD | 0 | 0–2^64−1 | Maximum logical live size of the log store. 0 means no limit. |
MetricRetentionDays | REG_DWORD | 90 | 1–3650 | Maximum age of metric samples. |
MetricRetentionMaxBytes | REG_QWORD | 0 | 0–2^64−1 | Maximum logical live size of the metric store. 0 means no limit. |
RetentionCheckIntervalMinutes | REG_DWORD | 60 | 1–1440 | How often the retention thread runs. |
RetentionDeleteBatchRows | REG_DWORD | 10000 | 100–100000 | Maximum rows deleted in one retention transaction. |
A.10 Querying #
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
QueryTimeoutMs | REG_DWORD | 30000 | 1000–300000 | Maximum query execution time. |
MaxConcurrentQueries | REG_DWORD | 128 | 1–4096 | Concurrent queries globally, streaming and non-streaming. |
MaxStreamingQueries | REG_DWORD | 64 | 1–1024 | Concurrent streaming queries globally. |
MaxDistinctStreamValues | REG_DWORD | 100000 | 1000–10000000 | Values tracked by one DISTINCT streaming query. |
MaxQueryMessageBytes | REG_DWORD | 65536 | 1024–16777216 | Maximum query request or response payload. |
A.11 Cross-type filtering #
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
CrossTypeWindowMs | REG_DWORD | 15000 | 1000–300000 | Centred window for cross-type event and log existence checks. |
CrossTypeMaxLookbackSeconds | REG_DWORD | 604800 | 3600–2592000 | Maximum range a cross-type filter may scan. |
A.12 The security subtree #
Read-path descriptors live under Machine\System\eventd\Security\ and
are not configuration in the sense above (§7.2):
Machine\System\eventd\Security\Events\*
Machine\System\eventd\Security\Events\<pattern>
Machine\System\eventd\Security\Logs\*
Machine\System\eventd\Security\Logs\<pattern>
Machine\System\eventd\Security\Metrics\*
Machine\System\eventd\Security\Metrics\<pattern>
The administrative descriptor is not here; it is admin_sd in
eventd-meta.db (§3.5).
A.13 When a change takes effect #
| Change | Effect |
|---|---|
| Every tuning parameter above | Applied immediately. |
| Socket paths | Restart. |
| Store paths | Restart. |
StorageShards | Restart. |
| Security descriptors | Next query; the registry watch invalidates the cache. |
Appendix B Constants
Peios / Advanced Peios / eventd
Wire-protocol constants — the framing, the ingestion limits, the query message ceiling — belong to the interfaces rather than to eventd and are in PSPU §3.A.
B.1 Access rights #
| Right | Bit | Value | Meaning |
|---|---|---|---|
EVENTD_READ | 0 | 0x0001 | Read records matching the pattern. |
EVENTD_CLEAR | 1 | 0x0002 | Delete records matching the pattern. Reserved; nothing uses it yet (§7.1). |
EVENTD_ADMINISTER | 2 | 0x0004 | Change eventd's own policy — the INDEX command. |
B.2 Generic mapping #
Passed to AccessCheck in the generic_read, generic_write,
generic_execute and generic_all fields.
| Generic right | Value | Composed of |
|---|---|---|
GENERIC_READ | 0x00020001 | EVENTD_READ | READ_CONTROL |
GENERIC_WRITE | 0x00020006 | EVENTD_CLEAR | EVENTD_ADMINISTER | READ_CONTROL |
GENERIC_EXECUTE | 0x00020001 | EVENTD_READ | READ_CONTROL |
GENERIC_ALL | 0x000F0007 | EVENTD_READ | EVENTD_CLEAR | EVENTD_ADMINISTER | DELETE | READ_CONTROL | WRITE_DAC | WRITE_OWNER |
EVENTD_ADMINISTER is in GENERIC_WRITE and deliberately not in
GENERIC_READ or GENERIC_EXECUTE (§7.1).
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. timestamp, cpu_id, sequence,
origin_class, event_type, effective_token_guid,
true_token_guid, process_guid, boot_id.
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 paths colliding with a header name 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 #
| Value | Origin |
|---|---|
| 0 | userspace |
| 1 | KMES |
| 2 | KACS |
| 3 | LCS |
The query language accepts these names as aliases (PSPU §3.23).
B.7 Synthetic event types #
| Type | Emitted when |
|---|---|
synthetic.startup | eventd starts and attaches to KMES. |
synthetic.shutdown | Graceful shutdown begins. |
synthetic.gap | A sequence gap is detected on a CPU. |
synthetic.config_change | A configuration value is applied at runtime. |
synthetic.storage_error | A write to any store fails. |
Payload schemas are in §3.2.
B.8 Metric types #
| Value | Type |
|---|---|
| 0 | counter |
| 1 | gauge |
| 2 | histogram |
Stored in series.type. The query language exposes the names, not the
numbers (PSPU §3.22).
B.9 Rollup functions #
| Value | Function |
|---|---|
| 0 | AVG |
| 1 | MIN |
| 2 | MAX |
| 3 | SUM |
| 4 | RATE |
| 5 | DELTA |
These name per-series rollup functions. Window aggregation keywords
have no identifiers of their own: AVG_OVER, MIN_OVER, MAX_OVER and
SUM_OVER map to AVG, MIN, MAX and SUM when no transform is present,
and RATE and DELTA rollups carry covered_ns for exact composition
(§5.6).
P50, P95 and P99 have no identifiers because percentiles are not composable and are never rolled up.
B.10 Log severity #
| Value | Meaning |
|---|---|
| 0 | Normal — standard output. |
| 1 | Error — standard error, or explicitly marked. |
Stored as an integer in logs.is_error; exposed as a boolean by the
query language, which accepts both forms (§4.2).
B.11 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.12 Schema versions #
| Store | Version |
|---|---|
| Event shard | 1 |
| Log store | 1 |
| Metric store | 1 |
eventd-meta.db | 1 |
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 / Advanced Peios / 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 per-drain-cycle arena avoids all of it: allocate a block at the start of the cycle, hand out sequential chunks by bumping a pointer, and release the whole block once the batch has been handed off. Per-event allocation cost falls from tens of nanoseconds to one or two, and the latency spikes disappear with it.
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 / Advanced Peios / 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/bin/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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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).
2.1.3 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 / Advanced Peios / 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 a key named by the recipe's signing configuration, and the signature entry is the last thing written into the archive before compression.
2.3 peipkg-repo
Peios / Advanced Peios / 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.
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 / Advanced Peios / 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 and hash.
Build reads the lock, fetches each package, checks its bytes against the hash the lock recorded, 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 / Advanced Peios / 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 / Advanced Peios / 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.
3.1 Configuration
Peios / Advanced Peios / 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 |
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. peipkg identifies a repository internally by the local handle, and compares an index's declared repository name against that handle — so a configuration whose handle differs from the descriptor's name produces a repository that adds successfully and is then skipped at every install, with a warning rather than an error.
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.
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.2 Adding a Repository
Peios / Advanced Peios / 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 no key matching an anchor verifies the descriptor, the add is refused. The error names the condition but not the fingerprints involved, which is the diagnostic a transcription error most needs.
peipkg does not display the fetched fingerprint alongside the supplied one, and does not prompt for confirmation before recording trust. A mismatch is caught — an anchor that does not match cannot verify anything — but the operator is not shown the two side by side.
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 writes the floor unconditionally, including for a repository already configured. Because the configured form of the command needs no arguments and reads as idempotent, re-running it is the route by which a recorded floor is replaced by whatever the current fetch returns.
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 / Advanced Peios / 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 / Advanced Peios / 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 checks apply to the active index. The archive index is verified for signature and identity but is not subjected to the freshness floor.
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 / Advanced Peios / 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 produces a warning, and resolution proceeds without that repository.
For a repository the system depends on, that means a package the operator expected to come from it is instead resolved from wherever else it is available, at a lower priority.
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 / Advanced Peios / 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, and enabling it produces no warning of its own on subsequent operations.
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 / Advanced Peios / 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 has no configured priority to compare, and peipkg skips the comparison — and with it the guard — rather than treating the unknown origin as maximally trusted.
The consequence is worth stating plainly: for packages left behind by a removed repository, the two gates above do not fire.
4.1 Inputs and Outputs
Peios / Advanced Peios / 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 / Advanced Peios / 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
when checking a plan for consistency. It does not apply it while
selecting a candidate for a noarch package's dependency, where the
architecture test is skipped entirely.
The visible consequence is a plan that could have been satisfied being rejected instead: a foreign-architecture candidate wins selection, and the consistency check then rejects the whole resolution rather than the one candidate.
4.3 Candidate Selection
Peios / Advanced Peios / peipkg / Resolution
When several available packages satisfy one dependency or goal, peipkg chooses between them by applying the following rules in order. The first rule that distinguishes two candidates decides.
-
An exact architecture match beats
noarch. A candidate whose architecture equals the system's primary architecture is preferred over anoarchcandidate of the same name. -
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. -
A higher package revision is preferred — already implied by rule 4, retained for clarity.
-
Ties break on the candidate's package name, by byte order, and then on its repository name.
4.3.1 When the rules run out #
Rules 1 to 6 do not order two different versions of the same package
matched through an unversioned provides: they carry no role version to
compare at rule 4, and they agree on name and repository at rule 6. Such
a pair is a complete tie, and the candidate enumerated first wins.
The outcome is therefore an artefact of the order the index was read in rather than a consequence of the rules — which means the same index served in a different order can install a different version.
4.4 Failure Conditions
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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. upgrade, downgrade, uninstall, and
undo resolve single-root.
The consequence is that a dependency declaring a root resolves into that root when the depending package is first installed, and is evaluated against — and if missing, installed into — the depending package's root on any later upgrade or removal.
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 #
Upgrading a package that is installed in several roots produces one transaction per root. Those transactions are applied in sequence and continue past a failure: a root whose upgrade fails is reported, and the remaining roots are still attempted.
5.1 Preconditions
Peios / Advanced Peios / 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.
Within a single root, the guarantee holds: every package is provided and verified before the first is materialised.
Across roots it does not. A cross-root transaction prepares and applies each root in sequence, so one root's payload is on disk in its final location before the next root's packages have been fetched or verified.
5.2 Validation
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.
Manifest-declared overrides are carried in the manifest and validated for base64 decodability, decoded length, and sort order. They are not checked against the payload — an override naming a path the package does not ship, or naming a symlink, or decoding to bytes that are not a security descriptor, is accepted — and they are not applied. No descriptor peipkg supplies is ever an override.
The operator-facing policy of PSPU §5.20 — surfacing each override, diffing it against inheritance, requiring confirmation for a non-official repository — has nothing to act on as a result.
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 / Advanced Peios / peipkg / Installation
An install path is computed by joining the payload-relative path to the installation root, and then used as an ordinary path.
5.5.1 What is protected #
The final component of every operation is symlink-safe. A staged file is created with exclusive-create semantics, so it cannot land on an existing file. A pre-existing entry at a destination is detected without following it, and is displaced by a rename, which does not follow a symlink at either end. A symlink already sitting at an install path is therefore renamed aside rather than written through.
5.5.2 What is not #
Ancestor components are resolved by the kernel afresh, following symlinks, on every call. peipkg holds no directory descriptor across an operation and re-walks each path string at each step.
Two consequences follow.
A symlink ancestor redirects a write, with no race involved. The format's rules permit a package to ship a symlink whose target resolves into a permitted destination, and permit a different package to ship a file whose path descends through that symlink's location — each entry validates in isolation. When the second package installs, the ancestor symlink is followed, and the file lands where the symlink points rather than where the archive said. The path recorded in the database is the archive's path, so the collision constraint of §5.3 compares the wrong name and does not fire.
The window between check and commit is the whole transaction. The check for a pre-existing entry runs while the transaction's intent is being journalled; the rename that acts on it runs in the apply phase, after every package in the transaction has been downloaded, decompressed and staged. Nothing is re-validated at commit, and no descriptor pins the directory in between.
5.6 Registration
Peios / Advanced Peios / 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 / Advanced Peios / 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 What peipkg does #
peipkg checks whether something exists at the install path. If it does, the existing entry is renamed aside as a backup and the staged file is renamed into place. If it does not, the staged file is renamed in.
The check is on existence alone. Ownership is not consulted, the existing content is not compared against what is about to be installed, and the operator is not asked.
The backup is discarded when the transaction commits, along with every other backup (§8.2), so the displaced content does not survive the operation.
5.7.2 The intended behaviour #
PSPU §5 leaves the handling of an unowned pre-existing file to the consumer, but the shape peipkg is designed for is three-way:
- Adopt it when its content is byte-identical to what would be installed. Recording the path as owned without rewriting it is safe, because the disk already holds exactly the intended bytes.
- Fail otherwise, naming the file, rather than overwriting something nobody claimed.
- Displace it when the operator explicitly authorises the overwrite, keeping the backup rather than destroying it.
None of the three is implemented. What happens today is the middle case without the failure: overwrite, then discard the evidence.
6.1 The File Diff
Peios / Advanced Peios / 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 |
peipkg does not compute the untouched category. Every payload entry of the new version is staged and renamed into place, whether or not its content differs from what is already there.
The effects are an upgrade that rewrites and backs up its whole payload rather than the changed part of it, inode and timestamp churn on files that did not change, and the configuration-file consequence described in §6.2.
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.
Directories left empty by an upgrade are not removed.
6.2 Configuration Files
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.
- 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).
Backups are discarded when the transaction commits.
6.6.3 Directories #
Directory entries are skipped. A package's directories are left in place, and its removal leaves the skeleton behind.
Because deleting the package's rows removes its ownership of those directories, they become owned by nothing, and no later operation reclaims them.
6.6.4 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. peipkg does not check for it during removal, and does not surface it as a database-integrity warning.
6.7 Modified Files
Peios / Advanced Peios / 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 two 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 not. Every owned path is renamed aside regardless of whether its content matches what was installed, and the operator is not told that something they customised is being removed.
6.7.2 The cost of checking #
Hashing every installed file at uninstall is expensive: a large package on slow storage takes seconds. The workable shape is to restrict the check to paths where customisation is expected — configuration, and locations policy names — and skip it for binaries and libraries, with the operator able to authorise removal, skip the file, or abort.
7.1 Atomicity
Peios / Advanced Peios / peipkg / Transactions
A transaction is the atomic unit of package work. Every install, upgrade, uninstall, grant, and revoke executes within one, even when it contains a single operation.
A transaction is atomic in the sense that either all of its operations succeed and become visible, or none of them visibly take effect. That holds under two kinds of failure:
- Logical failure — a step fails, an error is reported, or a cancellation is requested.
- System failure — power loss, kernel panic, hardware fault, at any point.
An uncommitted transaction is invisible to anything else on the system: the database does not show its operations, staged files have not replaced their targets, and no side effect has run. A transaction that completes its commit step is committed, and its operations are visible to everything afterwards.
7.1.1 The single durability boundary #
Atomicity rests on one fact: the package database is a transactional store, and the database's own commit is the transaction's durability boundary. No separate commit protocol is layered on top of it.
A crash before that commit leaves the journal's transaction pending, and recovery rolls it back. A crash after it leaves the transaction committed, and recovery has only cleanup to finish.
The transaction is never found partly committed, which is why recovery never has to complete a half-finished commit.
7.2 Scope
Peios / Advanced Peios / 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.
Each root's transaction is prepared and committed in sequence. That has a consequence for verification (§5.1): one root's payload is in place before the next root's packages have been fetched.
Recovery for a cross-root transaction is described in §7.8, and is the one place where roll-forward exists.
7.3 The Lock
Peios / Advanced Peios / 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 / Advanced Peios / peipkg / Transactions
Commit transitions a transaction from uncommitted to committed, in five steps.
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 the durability boundary. It is atomic, so the transaction is either fully committed or not committed at all.
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.
Because step 3 is a single atomic database commit, and because it carries both the new state and the journal's closure, there is no intermediate state to discover.
7.5 The Journal
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.
In-progress state is not visible outside peipkg's own process. Staged files do not appear at their final install paths until the apply phase, and journal rows describe a transaction that queries do not see.
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 #
Two 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.
7.8 Crash Recovery
Peios / Advanced Peios / 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.
Recovery of a cross-root operation therefore does roll forward. Each root's transaction persists the state a completion would need, and a root found pending after a sibling has committed is completed from that record rather than reversed.
A root found pending with no persisted payload cannot be completed and cannot safely be reversed, and recovery refuses it, leaving the operation for an operator.
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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.
What happens is different. Rollback errors are discarded at every site that triggers one, and the journal's transaction is then closed as rolled back regardless.
The consequences are worth stating plainly. If a rollback fails partway:
- 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 will report the affected files as modified, because
their recorded hashes no longer match what is on disk. That is the only
signal.
8.4 Indeterminate State
Peios / Advanced Peios / 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 #
None of the five.
There is no indeterminate state as a concept: a transaction is pending
or it is not. Writes are not refused after a failure; reads carry no
warning; there is no forensic report; peipkg recover offers only
rollback, with no way to accept the current state and discard the
journal; and recovery runs automatically at the head of every operation,
with no prompt.
The last point is the sharpest. Automatic rollback of a pending transaction is correct and is what §7.8 describes. But because nothing distinguishes a cleanly pending transaction from an indeterminate one, automatic resolution is the only behaviour available for both.
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 rolls back a pending transaction explicitly, and emits
an audit event where the automatic path does not.
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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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/bin/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/bin/registryd pointing at
/usr/sbin/loregd is written as ../sbin/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 / Advanced Peios / 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 checked for structural sanity: absolute, within the length limit, lexically clean, with a non-empty first component.
9.2.2 What peipkg does not check #
Neither a target nor a claim path is checked against the permitted install destinations, and neither is subject to the payload path-syntax constraints — normalisation form, control characters, backslashes, component length.
A target is not checked against the declaring package's own payload either. The producer-side library offers that check and pekit runs it, but peipkg does not run it at install time, so a package built by anything else can declare a target it does not ship.
The visible consequences, in order of severity: a claim path outside the managed tree is materialised there, displacing whatever was at that path into a backup that the commit then discards; a target naming a path the package does not own produces a link pointing at whatever is there; and a target naming nothing produces a dangling link.
9.3 Auto-Claim
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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.
The check reads the ownership table as it stands before the transaction's own rows are written, which happens at commit. So a transaction that installs both a package owning a path and a provider claiming that same path sees no owner, queues both a payload operation and a claim operation for one destination, and commits both — leaving the database holding an ownership row and a claim-link row for one path.
A package that owns a directory at the claim path is waved through rather than treated as a collision, and the directory is renamed aside and replaced with the link.
The reverse direction is unguarded: nothing stops a package payload installing over an existing claim link. The link is renamed aside, the payload file takes the path, and the claim-link row survives — after which reconciliation compares its record against its own desired set, finds them equal, and never notices the link is gone.
9.6.4 Repointing #
A holder swap repoints every one of the role's links, within a single transaction.
Each repoint is performed as two renames: the old link is renamed aside as a backup, then the new link is renamed into place. The path is absent between the two.
9.7 Withdrawal
Peios / Advanced Peios / 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 'altregd', 'thirdregd'
also provide it. Run 'peipkg claim registryd grant 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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. upgrade, downgrade, uninstall, and
undo resolve single-root, where a dependency's root field is inert
and the dependency is evaluated in the depending package's root instead.
So a cross-root dependency is placed correctly when the depending package is installed, and is evaluated in the wrong root on every later operation.
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 / Advanced Peios / 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 timestamp everything is stamped with |
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, and root. It carries a digest of the manifest, so that building against a stale lock is caught.
The digest covers the packages, their constraints, and their repository pins. It does not cover root declarations or per-package root placement, so a manifest edit that only moves a package between roots, or changes where a root lives, produces the same digest — and a build without an explicit update silently reproduces 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, checks its bytes against the hash the lock recorded, validates its format, and cross-checks the archive's own manifest against the lock entry. Every package is verified before any is extracted.
What is not checked is the inline signature against a trust set. The resolve phase performs the trust ceremony and then discards the trust state with the temporary database it was built in, so the build phase has hashes but no keys. The chain that remains is: the index was signed, the lock records the index's hash, the bytes match the hash. A package whose signing key has since been revoked is accepted by that chain and refused by peipkg.
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.
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.
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 registered but never created. The registration points at a directory that does not exist, and addressing that root on the composed system fails when peipkg tries to open its database.
11.1 The Recognised Set
Peios / Advanced Peios / peipkg / Side Effects
A package cannot ship code that runs at install time. It can declare that one of three standard maintenance operations is required, and peipkg invokes it.
| Identifier | Rebuilds |
|---|---|
ldconfig | The shared library cache, /etc/ld.so.cache, and shared library symlinks |
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 is rejected, and a duplicate within the array is rejected.
11.1.1 When each is required #
A package containing shared libraries declares ldconfig; one
containing none does not. 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. It does
not validate them against the payload: a package shipping shared
libraries with no ldconfig declaration packs, installs, and leaves the
library cache stale, and a package declaring ldconfig while shipping
no libraries invokes it for nothing.
The producer is where that check belongs — the payload map it would examine is already walked to derive shared-library capabilities — and neither the producer nor the consumer performs it.
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 / Advanced Peios / peipkg / Side Effects
Each side effect maps to one fixed command.
| Identifier | Invoked as |
|---|---|
ldconfig | /bin/ldconfig, no arguments |
depmod | /bin/depmod -a |
man-db | /bin/mandb -q |
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 -a acts on the running kernel, since no release is named.
A transaction installing modules for a kernel release other than the one currently booted — which is the normal case during a kernel update, and always the case during an image build — rebuilds the running kernel's dependency cache and leaves the installed release's unbuilt, so those modules are unloadable until something rebuilds it.
A package shipping modules for two releases gets one invocation, for neither of them necessarily.
11.2.3 The root #
The tools invoked are the host's, at the host's absolute paths, with no root argument.
An operation against an alternate installation root therefore rebuilds the host's caches rather than the target's — once per participating root, in a cross-root transaction, and never for the root whose contents changed.
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 / Advanced Peios / 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
ldconfig 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 library cache is rebuilt after every package's libraries 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 What does not schedule #
An operation that only removes files schedules nothing, so removing the last package that owned a shared library or a kernel module leaves the corresponding cache naming something that no longer exists (§7.6).
An upgrade does schedule effects implied by the files it removed, as well as those the new version declares.
12.1 The Recipe Family
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 |
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 |
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.
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 / Advanced Peios / 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 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.3 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.4 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.5 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.6 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 / Advanced Peios / 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}}, {{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}}; for a version carrying
an epoch or a tilde, the model does not parse it and the components
render empty.
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 / Advanced Peios / 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: the workspace layer, the source layer, the recipe layer, the selected environment file, and the keyring, in that order, wrapped by the recipe's wrapper if it declares one.
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.
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 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.
12.7 Reproducibility
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / peipkg / Security
Every install, upgrade, uninstall, refresh, and recovery emits an audit event.
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 an event carries #
| Field | Where it lives |
|---|---|
| Operation type | The event's type tag |
| The caller's identity | The kernel-stamped header |
| Target packages | The payload: name, version, architecture |
| Outcome, with a rejection reason | The payload |
| Transaction identifier | The payload |
| Timestamp, UTC | The payload |
| Source repository | Not carried |
Identity is not in the payload and is not peipkg's to write. The kernel stamps the caller's effective token, its true token, and its process identity onto every emission, where they cannot be forged or suppressed by the emitting program. That is a stronger guarantee than a payload field: peipkg could lie about a payload field, and cannot lie about a header the kernel wrote.
The source repository is not recorded on an install or upgrade event, although it is known. A plan drawing packages from several repositories names none of them.
A committed cross-root operation emits its success event without a transaction identifier, so it cannot be joined to the transaction ledger or to the kernel's own record of the file operations it performed.
13.3.2 Event types #
| Type | Emitted for |
|---|---|
peipkg.install | A successful install |
peipkg.upgrade | A successful upgrade — and a downgrade, and an undo |
peipkg.uninstall | A successful uninstall |
peipkg.refresh | A repository refresh |
peipkg.transaction-failed | A transaction that was rolled back |
peipkg.recovery | A recovery-mode resolution |
peipkg.authorisation | An operator authorisation record |
peipkg.repo-add | A repository add |
peipkg.repo-remove | A repository remove |
peipkg.config-change | A trust-policy or transport-flag change |
peipkg.claim | A claim grant or revoke |
Downgrade and undo are deliberately recorded as upgrades, since the set carries no downgrade type.
peipkg.config-change is declared and never emitted. A repository
re-added with a weakened signature policy or an enabled transport
allowance produces an event indistinguishable from a routine add, so
trust-policy history cannot be reconstructed from the stream.
A refresh in which some repositories succeeded and others failed emits one event with a rejection outcome, an empty repository field, and a count in its detail.
13.3.3 Successes and failures #
A committed operation emits a success event; one that is rejected or rolled back emits a separate failure event. Rejection reasons and error text travel in the detail field.
An operator who declines at a prompt emits nothing: the transaction never started.
An automatic recovery at the head of an ordinary operation emits
nothing. The same rollback performed deliberately through the recover
command does. peipkg recover's own failure paths emit nothing either.
peipkg-compose emits nothing at all.
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 event.
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 / Advanced Peios / 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 | 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 The channel is not distinguished #
An authorization prompt and the routine prompt read from the same input stream and accept the same affirmative. The distinctness the model calls for is a property of what is displayed, not of what is accepted.
A script piping affirmatives satisfies every elevated gate in a plan along with the routine one. The property survives for a person at a terminal and does not survive automation.
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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / 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 #
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 #
Recovery rolls forward instead. A root found pending after a sibling committed is completed from the state its journal persisted, because a committed sibling cannot be undone.
A pending root carrying no persisted state can be neither completed nor
safely reversed, and recovery refuses it — blocking further work in that
root until peipkg recover is run.
A pending cross-root transaction found by an ordinary single-root operation is refused rather than recovered, for the same reason.
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 / Advanced Peios / 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 #
Discards the error, closes the journal's transaction as rolled back, and returns.
14.2.3 What that means for the next operation #
Recovery looks for a pending transaction and finds none, so it does not retry. The history shows an authoritative-looking rolled-back record. Nothing tells the operator the rollback did not complete.
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 #
Identify the affected paths, decide whether the old or the new content is wanted, remove the leftover siblings, and reinstall the affected packages so that the database and the filesystem agree again.
14.3 A File That Does Not Match
Peios / Advanced Peios / 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 / Advanced Peios / 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 / Advanced Peios / peipkg / Failure Modes
peipkg does not check free space before it starts. Exhaustion is discovered when a write fails, 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 Planning ahead #
A transaction's additional requirement is the staged new and changed content, aggregated across every operation. Backups cost nothing.
Because a transaction can span several filesystems — installation roots and destinations within a root can be separate mounts — the useful figure is per filesystem rather than a single total.
Nothing computes either figure. The manifest's declared installed size is available and is used to bound decompression, not to plan space.
14.6 A Broken Claim
Peios / Advanced Peios / 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 that landed outside the managed tree, because claim paths are not constrained to the permitted destinations, displaces whatever was there. The displaced file went to a backup that the commit discarded.
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 / Advanced Peios / 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 | Invoked |
|---|---|
ldconfig | /bin/ldconfig |
depmod | /libexec/depmod -a |
man-db | /bin/mandb -q |
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 / Advanced Peios / peipkg
Every event described here is emitted into the kernel event subsystem (§13.3). The caller's identity is stamped into the event header by the kernel and is not part of the payload.
These types also appear in the Peios Events Index, alongside every other event the system emits.
B.1 Types #
| Type | Emitted for | Emitted today |
|---|---|---|
peipkg.install | A successful install | yes |
peipkg.upgrade | A successful upgrade, downgrade, or undo | yes |
peipkg.uninstall | A successful uninstall | yes |
peipkg.refresh | A repository refresh, successful or partially failed | yes |
peipkg.transaction-failed | A rejected or rolled-back transaction | yes |
peipkg.recovery | A recovery resolved through peipkg recover | yes |
peipkg.authorisation | An operator authorisation record | yes |
peipkg.repo-add | A repository add | yes |
peipkg.repo-remove | A repository remove | yes |
peipkg.claim | A claim grant or revoke | yes |
peipkg.config-change | A trust-policy or transport-flag change | no |
B.2 Payload fields #
| Field | Content |
|---|---|
txn_id | The transaction identifier |
outcome | success, rejection, or rollback |
repo | The repository, for repository operations |
detail | The rejection reason, the operation count, or the authorised action |
timestamp | RFC 3339, UTC |
packages | Name, version, and architecture per package |
B.3 Where events do not appear #
- An install or upgrade event carries no source repository, although one is known.
- A committed cross-root operation's success event carries no transaction identifier.
- Automatic recovery at the head of an ordinary operation emits nothing.
peipkg recover's failure paths emit nothing.- Declining at a prompt emits nothing.
- Enabling insecure transport, and installing unsigned content under an
optionalpolicy, emit no authorisation record. peipkg-composeemits 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.
On a kernel with no emit call, emission is a silent successful no-op.
Debugging the kernel
Peios / Developing for Peios / Debugging the kernel
Most questions about why the Peios kernel did something — why an access was denied, why an event never reached userspace, why a registry lookup stalled — are answered from inside the kernel, before any userspace tool can see the state involved. Peios makes that interior observable through the same tracing infrastructure the rest of the Linux kernel uses: static tracepoints.
The PKM security subsystems each expose a tracepoint system:
kacs:— the access-control decisions. Every instrumented KACS hook records its verdict (allow/deny), the object it acted on (inode number and superblock magic — never a pathname), and areasoncode naming the exact return path it took. This is where "why was this denied?" is answered.kmes:— the health of the event substrate: ring-buffer drops, capacity swaps, rate-limit throttling, backpressure. These trace the machinery, not the security events KMES ships to userspace (those are the event stream).lcs:— the registry source device: request/response round-trips, timeouts, transaction state transitions, source mark-downs.
Because these are ordinary kernel tracepoints, everything the kernel's tracing stack can do applies unchanged: enable individual events or whole subsystems, attach ftrace filters and triggers, sample with perf, or attach eBPF programs. They cost nothing when disabled (a patched-out branch) and record structured fields rather than formatted text, so you filter on ret, reason, or an inode number directly.
The Kernel tracepoints page covers how to enable them — at runtime through tracefs, or from the very first moments of boot through the kernel command line — and how to read what they emit.
Kernel tracepoints
Peios / Developing for Peios / Debugging the kernel
PKM exposes its security subsystems through three tracepoint systems — kacs:, kmes:, and lcs: — registered with the standard Linux tracing infrastructure. This page shows how to turn them on and read them.
Discovering the events #
Every event and its fields are self-describing through tracefs. The live catalog is authoritative — prefer it over any static list:
# every PKM tracepoint
# the fields (and their symbolic decodings) of one event
The numeric reason, op, and state codes carried by these events are a stable, append-only diagnostic ABI defined in <pkm/trace.h>; the format file maps them back to their symbolic names for you.
Enabling at runtime #
Enable a whole subsystem, or a single event:
Because the fields are structured, you filter in the kernel rather than grepping text. To see only denials:
To watch a single inode, or one reason:
perf and eBPF attach to the same tracepoints by name — e.g. perf record -e kacs:kacs_file_access, or a tracepoint:kacs:kacs_file_access probe from bpftrace.
Enabling at boot #
The most common reason to reach for these is a decision that happens before userspace exists — the access checks that fire as the root filesystem mounts. tracefs is not available that early, so enable the events on the kernel command line and route them to the console with tp_printk:
trace_event=kacs:*,kmes:*,lcs:* tp_printk
trace_event= enables the listed events as the tracing subsystem initialises — which happens before the PKM LSM itself initialises, and well before the first access check — so no early decision is missed. tp_printk prints each enabled tracepoint to the kernel log, giving you a complete decision transcript on the console with no userspace involved. Narrow the selection (trace_event=kacs:kacs_file_access) to cut the volume.
Reading a KACS access decision #
A kacs: access-decision event answers "what did KACS decide about this object, and why?". The key fields:
verdict—allowordeny, derived fromret(0is allow; a negative errno is a denial). Filter onretfor machine use.reason— the specific return path taken, as a symbolic name (e.g.decision,unmanaged,no-token,pip-context). The same object can be denied for very different reasons; this names which one.ino/sb_magic— the inode number and the filesystem's superblock magic, identifying the object without disclosing its path.mount_policy— the resolved mount policy for the object's filesystem, which frequently explains a denial on an unmanaged or synthesis-only mount.access— the desired-access mask being checked.
For a worked example of tracing a specific denial end to end, see Debugging a denial.
Writing regman pages
Peios / Developing for Peios / Documenting configuration
The registry stores a value's type and bytes, but never its meaning — it does not know that a queue depth must be at least 16, or what changing it costs. That knowledge ships with the software that owns the key, as documentation regman reads. From the operator's side this is the registry manual; from yours, the package author's, it is a file you write and install.
This page is how you write one. The package that owns a set of registry keys is the package that documents them — the only arrangement that stays correct as packages come and go.
Where the documentation lives #
regman reads a drop-in directory, /usr/share/regman/. Each *.regman file in it is one provider, and the provider's name is the file's stem:
/usr/share/regman/
kmes.regman # provider: kmes
exampled.regman # provider: exampled
A package documents its whole registry surface in one file. There is no central index to register with and no install-time hook: dropping the file in is the whole act, and removing it on uninstall is the whole undo. A missing directory simply means nothing is documented — not an error.
You ship the fragment the same way you ship any other file. Have your build target install it, then map it into the payload from the package file's [files] table:
[]
= "usr/share/regman/exampled.regman"
If the fragment is hand-written rather than generated — which it usually is — keep it beside the recipe and take it from there, no build target involved:
[]
= "usr/share/regman/exampled.regman"
See Packages for ref resolution, and Multi-package recipes if one recipe produces a family of packages that each document their own keys.
The shape of a fragment #
A fragment is a sequence of records, each documenting one key or one value. A record is a fence line, a small header, a blank line, then a Markdown body:
--- machine\system\exampled maxqueuedepth
canonical: Machine\System\Exampled MaxQueueDepth
type: REG_DWORD
default: 1024
valid: 16–65536
applies: restart
Maximum number of jobs held in the spool queue before new submissions
are rejected with EAGAIN.
Raising this lets the queue absorb larger bursts at the cost of memory.
The queue is preallocated, so the change takes effect at the next service
restart rather than live.
Four parts, in order:
- The fence line —
---(three dashes, one space) followed by the anchor: the case-folded, lowercased lookup tokenregmanmatches against. You do not write this by hand;regman fmtbakes it fromcanonical(see The fmt/lint workflow, below). - The header —
key: valuelines, one per line, until the first blank line. Keys are case-insensitive; values are trimmed. Unknown keys are ignored, so a typo'd field is silently dropped rather than rejected — watch for it. - A blank line — this is what ends the header and starts the body. A record with no blank line has no body.
- The body — Markdown, rendered when the page is shown.
Key docs and value docs #
There is no kind: field. A record is a value doc if it carries any of type, default, valid, or applies; otherwise it is a key doc. That is the whole distinction — a key doc is just a record that omits all four.
A key doc introduces a subtree. Its body explains the shared semantics once (validation philosophy, security intent, how the subsystem reads the keys), and regman <key> renders that body followed by an auto-generated index of the values beneath it:
--- machine\system\exampled
canonical: Machine\System\Exampled
The Exampled spooler reads its tuning parameters from the values under
this key. Compiled-in defaults apply until LCS is available; after that
Exampled reads, validates, and applies each value, then watches the
subtree for later changes.
A value doc documents one knob. The four value fields are what fill in its card:
| Field | On | What it tells the reader |
|---|---|---|
canonical | every record | The original-case Path[ Value] shown in the heading. The one required field. |
type | value docs | Registry type tag — REG_DWORD, REG_QWORD, REG_SZ, … |
default | value docs | The value used when nothing is set. |
valid | value docs | The range, set, or constraint a sensible value must satisfy. Human-readable prose. |
applies | value docs | When a change takes effect: live, restart, or reboot. |
deprecated | either | Present ⇒ the item is being retired; the value is the replacement or a note. Renders as a banner at the top of the card. |
canonical is the only field any record must have — a record without it is dropped and flagged. The four value fields are not individually enforced, but each one you omit is a blank on the card, and applies in particular is the field operators reach for most. Treat a value doc as incomplete until it carries all four.
The body's first line is the summary #
There is no summary: field. The first non-empty line of the body is taken as the one-sentence summary — it is what shows next to the value name in a key doc's index and in regman -k search results. So write the body like a good commit message or docstring: lead with one self-contained sentence, then elaborate in the paragraphs below.
The body is Markdown: **bold**, `code`, headings, and bullet lists render; prose is wrapped to the terminal width. On a non-tty (piped) it renders plain, honouring NO_COLOR.
A complete fragment #
/usr/share/regman/exampled.regman, one key doc plus one value doc, as a package would ship it:
--- machine\system\exampled
canonical: Machine\System\Exampled
The Exampled spooler reads its tuning parameters from the values under
this key. Compiled-in defaults apply until LCS is available.
Security is on this key, not per value: every value inherits this key's
Security Descriptor.
--- machine\system\exampled maxqueuedepth
canonical: Machine\System\Exampled MaxQueueDepth
type: REG_DWORD
default: 1024
valid: 16–65536
applies: restart
Maximum number of jobs held in the spool queue before new submissions
are rejected with EAGAIN.
Raising this lets the queue absorb larger bursts at the cost of memory.
The queue is preallocated, so the change takes effect at the next service
restart rather than live.
That renders, for regman Machine\System\Exampled MaxQueueDepth, as:
Machine\System\Exampled MaxQueueDepth documented by exampled
Type REG_DWORD
Default 1024
Valid 16–65536
Applies restart
Maximum number of jobs held in the spool queue before new submissions
are rejected with EAGAIN.
Raising this lets the queue absorb larger bursts at the cost of memory.
The queue is preallocated, so the change takes effect at the next service
restart rather than live.
— and the bare regman Machine\System\Exampled renders the key body plus the index:
Machine\System\Exampled documented by exampled
The Exampled spooler reads its tuning parameters from the values under
this key. Compiled-in defaults apply until LCS is available.
Security is on this key, not per value: every value inherits this key's
Security Descriptor.
Values
MaxQueueDepth Maximum number of jobs held in the spool queue before n…
The fmt/lint workflow #
You write canonical with whatever casing reads best. The fence anchor — the folded, lowercased token the scanner matches — is derived from it, because the registry compares keys case-insensitively (Unicode Simple Case Folding) while a filesystem does not. Keeping the two in sync by hand is exactly the kind of error a tool should own, so two commands own it:
The workflow is:
- Write each record with its
canonicaland body. Open the record with a fence line — you can put any placeholder after the dashes, e.g.--- x;regman fmtoverwrites it. - Run
regman fmt. It rewrites every fence to--- <fold(canonical)>and prints<file>: anchors updatedif anything changed. It is idempotent — a second run is a no-op. A record missingcanonicalis skipped and reported. - Run
regman lint. It reports any record with nocanonical:and any fence anchor that disagrees with itscanonical(anchor for ...: fence has X, expected Y — run regman fmt). A clean fragment prints nothing and exits 0.
Wire regman lint into your build or CI so a malformed fragment fails the build rather than shipping silently broken.
Preview before you ship #
regman honours REGMAN_DIR, so you can point it at your working directory and see exactly what an operator will see, without installing anything:
REGMAN_DIR=.
REGMAN_DIR=.
REGMAN_DIR=.
Rules the tools won't catch #
regman lint checks structure and anchors. These conventions it does not enforce — they are on you:
- Never start a body line with
---(three dashes, a space, then text). Any such line is a fence — it will be read as the start of a new record and silently split your page in two. A bare---on its own line (a Markdown thematic break) is fine; it's the--- textform that bites. If you need a horizontal rule, use***or___. - Keep
appliesto the vocabularylive/restart/reboot. A short parenthetical is fine (live (ring-buffer swap)); a freeform sentence defeats the at-a-glance purpose of the field. - Lead the body with one summary sentence (above) — an empty or buried first line leaves a blank in the key index and in
-kresults. - Don't invent fields. Unknown header keys are ignored, so
defualt:orapplies-to:vanish without warning. There is deliberately noaccess:/SD field (the deployed Security Descriptor is live state a shipped file can't know — say access intent in prose if it matters) and nosince:field (the package version already records provenance).
Two documenters, one key #
If two installed packages document the same (key, value), regman does not pick a winner — it shows both records under a documented by N packages: banner, flagging the overlap as the anomaly it is. That's a safety net, not a feature: a key should have exactly one documenting package, the one that owns it. If you find yourself documenting another package's keys, that's usually a sign the ownership is wrong.
Keeping lookups fast #
Lookup is correct with no index at all — regman scans the corpus directly, and at realistic sizes that's a few milliseconds. An optional index (regman index, kept warm by a supervised regman index --watch) just skips the scan; it can be absent or stale without ever producing a wrong answer. None of this is your concern as a fragment author, with one exception worth knowing: package installs must replace a fragment by atomic rename rather than truncate-and-rewrite, so a lookup never sees a half-written file. peipkg already does this, so simply shipping the file the normal way is correct.
See also #
- The registry manual — the operator's view of
regman: reading a knob-card, the-ksearch, and the intent-not-state boundary. - Configuration, not storage — why the registry holds values without their meaning, the idea a
.regmanpage exists to serve. - Packages and the recipe format reference — installing the fragment as part of your package.
What is the Peios SDK
Peios / Developing for Peios / SDK basics
The Peios SDK is how your own software talks to Peios.
Peios exposes its security world — identities, tokens, access checks, file security, the registry, and the audit/event stream — through a kernel interface built out of raw syscalls, ioctls, and packed byte buffers in the MS-DTYP wire formats. That interface is precise, but it is not something you want to hand-assemble from C. The SDK is the layer that lifts it into ordinary functions you can call: build a SID, open a token, run an access check, read a registry value, emit an event.
It is a C ABI, not a C-only library. The shipping product is a set of hand-written C headers and a shared object with a frozen application binary interface. Anything that can call C — Go via cgo, Rust via bindgen, Python via ctypes, Zig, C++ — can link it and get the same surface. The library happens to be implemented in Rust, but that is invisible across the boundary: callers see peios_* functions, <peios/*.h> headers, and errno.
Who it's for #
You want the SDK if you are building software that runs on Peios and needs to participate in its security model — a service that checks whether a caller may perform an action, a tool that reads or writes security descriptors, an agent that emits audit events, or anything that reads and writes the registry.
You do not need it to simply run on Peios. Ordinary POSIX programs run under Peios's Linux-compatibility surface without ever linking libpeios. The SDK is for programs that want to reach past POSIX and speak KACS, LCS, and KMES directly. If you are administering a running system rather than writing code against it, the Peios operator documentation is the place to start.
The three surfaces #
Peios's kernel boundary has three subsystems, and the SDK mirrors them one-to-one. Everything in the SDK belongs to one of these:
| Subsystem | What it is | SDK headers |
|---|---|---|
| KACS — Kernel Access Control Subsystem | Identities, tokens, access decisions, file security, and process security. The heart of the model. | <peios/security.h>, <peios/token.h>, <peios/access.h>, <peios/file.h>, <peios/process.h> |
| LCS — the registry | A hierarchical, transactional, secured key/value store — Peios's system configuration database. | <peios/registry.h> |
| KMES — the event system | The msgpack-framed audit and event stream: emit events, consume them. | <peios/msgpack.h>, <peios/event.h> |
The umbrella header <peios.h> pulls in all of them; you can also include the individual concept headers for a tighter compile surface.
libpeios, librsi, and the substrate #
The SDK is two libraries on a shared foundation, each with its own role:
- libpeios — the userspace C-ABI library for KACS, LCS, and KMES. It is the registry client and the whole of the access-control and event surface, and it is what the bulk of this documentation describes. Link
-lpeios, include<peios.h>. - librsi — the library for implementing a registry source (a storage backend): the provider counterpart to libpeios's registry client. Where libpeios reads and writes the registry, librsi is what a program uses to be the thing that holds the data and answers the kernel. Link
-lrsi, include<rsi.h>. See Registry sources. - peios-cabi — the internal C-ABI substrate both libraries are built on (the allocator wiring, the errno slot, the syscall/ioctl wrappers, the getxattr-style buffer helpers). You never link it directly; it is shared plumbing, documented here only so the conventions it sets make sense.
So the registry has two sides in this SDK: the client side in libpeios (open keys, read/write values) and the source side in librsi (back the keys and values, serve the kernel's requests). Most programs are clients; you reach for librsi only when you are providing storage.
Because the two libraries share one substrate, one error model, and one audience, they share one doc set. Learn the conventions once and they hold across both.
What this documentation promises #
This is user-facing documentation, not a specification. The authoritative byte-level contracts live in the PCSA specifications — PCDS for the core data structures, PGSS, PSPK and PSPU for the interfaces — and in the <pkm/*.h> kernel headers; where a detail is normative, this documentation points you at the specification that owns it. Where a detail is about this implementation rather than the contract, it points at the Peios Kernel manual instead. What you get here is the working knowledge to use the library well: what every function does, what it returns, how memory and errors flow, and how the pieces fit together — with enough coverage that you should not need to open the library's source to answer a question. When you find a gap, that is a documentation bug worth reporting.
Where to go next #
- Installing and linking — the packages, the headers, and how to build against the library.
- Library conventions — the error model, the memory rules, and the buffer protocol that every function in the SDK follows. Read this once; it saves you re-learning it per module.
- Your first program — a small, complete program you can compile and run.
Installing and linking
Peios / Developing for Peios / SDK basics
libpeios ships as a small set of packages, split the same way a C library conventionally is: a lean runtime package that programs depend on, and a development package that carries the headers and the linker symlink. You install the runtime everywhere the library is used and the development package only where you compile.
The packages #
| Package | Contents | When you need it |
|---|---|---|
libpeios | The versioned shared object libpeios.so.0 (the runtime soname). | At runtime, on every machine that runs a program linked against the library. Pulled in automatically as a dependency of anything built against it. |
libpeios-devel | The public headers (peios.h + peios/*.h), the unversioned libpeios.so linker symlink, and the peios.pc pkg-config descriptor. Depends on a matching libpeios. | At build time, on machines where you compile. |
libpeios-static | The static archive libpeios.a. | Only if you link the library statically instead of against the shared object. |
libpeios-debuginfo | Split DWARF debug info, build-id indexed. | Debugging or profiling through the library. |
libpeios-debugsource | The referenced Rust sources for the debug info. | Stepping into the library's own source in a debugger. |
Installing libpeios-devel pulls in the matching libpeios runtime automatically — the development package pins the exact runtime version whose ABI its headers describe, so the headers you compile against and the shared object you load can never disagree.
The headers #
Everything lives under <peios/…>, with one umbrella:
or, for a tighter compile surface, just the concept headers you use:
The headers are hand-written and are the real API — they carry the prose docs and the layout notes that a generated header can't. (Internally they are checked against the library's Rust surface by a cbindgen-based verifier, so they cannot silently drift from what the shared object actually exports. You do not interact with that machinery; it just means the header you read is the contract you get.)
The <pkm/*.h> dependency #
The Peios headers do not re-invent the kernel's wire constants — they use them directly. So <peios/security.h> includes <pkm/sid.h> and <pkm/sd.h>, and the other headers pull in their matching <pkm/*.h> UAPI headers for the KACS_*, LCS_*, and KMES_* constants and the #[repr(C)] argument structs. Those PKM kernel UAPI headers must be on your include path when you compile. They ship with the Peios kernel headers; on a normal Peios development install they are already where the toolchain looks. If a build fails with fatal error: pkm/sid.h: No such file or directory, that is what is missing — add the kernel UAPI include directory to your compiler's search path.
Compiling and linking #
With pkg-config (recommended) #
The development package installs a peios.pc descriptor, so pkg-config knows the include and library flags:
pkg-config --cflags peios expands to the include flags and --libs peios to -lpeios plus the library directory. This is the form to prefer — it stays correct across install prefixes and multiarch library directories.
By hand #
If you are not using pkg-config, link against -lpeios directly:
Add -I / -L flags if your headers and library live outside the compiler's default search paths.
Statically #
Install libpeios-static and point the linker at the archive:
The static archive is also what the library's own integration tests link against, so it is a fully supported way to build. Static linking folds the library into your binary — you then do not need the libpeios runtime package on the target, though you still need whatever C runtime your program uses.
Linking from other languages #
Because libpeios is a C ABI, any language with a C FFI can call it. The shape is always the same: point the FFI at the libpeios.so.0 shared object (or the static archive), declare the peios_* functions with their C signatures, and follow the library conventions for buffers and errors exactly as a C caller would.
- Rust — use the maintained bindings: the safe
peioscrate (or the rawpeios-sys), covered in Using the SDK from Rust. You don't need to hand-rollbindgen. (The library is Rust internally, but the crates still bind it strictly as a C library through the stable ABI — the supported entry point.) - Go — cgo against
<peios.h>with#cgo pkg-config: peios. - Python —
ctypesorcffiagainstlibpeios.so.0. - C++ — include the headers directly; they are wrapped in
extern "C"and compile as C++.
The one rule that matters across every language: the errno-based error model and the caller-buffer / two-call buffer protocol are part of the contract, not a C convenience. Read Library conventions before you wrap the API in another language's idioms.
librsi — the registry-source library #
Everything above describes libpeios. If you are writing a registry source, you link the sibling library librsi instead (or as well). It is built on the same peios-cabi substrate, follows the identical library conventions — raw fds, the int/ssize_t error model, the borrow discipline — and is packaged exactly the same way libpeios is.
The packages #
| Package | Contents | When you need it |
|---|---|---|
librsi | The versioned shared object librsi.so.0. | At runtime, wherever a source runs. |
librsi-devel | The public headers (rsi.h + rsi/*.h), the librsi.so linker symlink, and the rsi.pc pkg-config descriptor. Depends on a matching librsi. | At build time. |
librsi-static | The static archive librsi.a. | Only for static linking. |
librsi-debuginfo | Split DWARF debug info. | Debugging or profiling through the library. |
librsi-debugsource | The referenced Rust sources. | Stepping into the library's own source. |
As with libpeios, librsi-devel pulls in the matching librsi runtime and the kernel-headers package — the <rsi/*.h> headers include <pkm/lcs.h>, so the same UAPI-header requirement described above applies.
Headers and linking #
Include the umbrella or the individual concept headers:
/* or: */
and compile with pkg-config (the descriptor's module name is rsi):
or link -lrsi directly, or against librsi.a for a static build — exactly the three forms shown for libpeios above. The two libraries are independent: a program can be a client (libpeios), a source (librsi), or both, linking each as needed.
Your first program
Peios / Developing for Peios / SDK basics
This is a complete program you can compile and run. It does not touch the kernel — it works entirely with the security-descriptor vocabulary from <peios/security.h>, which makes it a safe first outing: no privileges, no live tokens, just the conventions from Library conventions put to work.
The task: take a security descriptor written in SDDL text, turn it into wire bytes, read its owner, and print that owner's SID in string form. Along the way you exercise the two-call buffer protocol twice, parse with a zero-copy view, and handle errors the libpeios way.
The program #
int
Building and running #
Expected output:
security descriptor: 44 bytes
owner: S-1-5-32-544
S-1-5-32-544 is the well-known SID for the local Administrators group — which is exactly the BA you wrote in the SDDL string. (The byte count may differ across versions; the owner will not.)
What just happened #
Every convention from the previous page showed up here:
- The two-call protocol, twice.
peios_sddl_parse_sdandpeios_sid_formatwere each called first with aNULL/0buffer to learn the size, then again to fill a right-sized allocation. Neither could ever truncate: a too-small buffer would have returned-1withERANGE, not a partial result. - A string length excludes the NUL. That is why the SID string buffer was
slen + 1. - A zero-copy view borrowed our buffer.
ownerpointed intosd, sosdhad to stay alive and unmodified until after the finalpeios_sid_format. Freeing it earlier would have leftownerdangling — the one mistake this pattern invites, and the reason step 5 frees last. - Errors came back through the return value and
errno. Every call was checked;strerror(errno)explained any failure. No exceptions, no out-of-band error channel.
A shortcut worth knowing #
Not every buffer needs a probe. Some results have a known ceiling, and the library gives you a constant so you can use a fixed stack buffer and skip the first call entirely. A SID is the classic case — it is never larger than PEIOS_SID_MAX_BYTES:
unsigned char sid;
ssize_t n = ;
/* n > 0: `sid` holds S-1-5-32-544 in wire form, no malloc, no probe. */
Use the probe when a length is genuinely unbounded (strings, whole descriptors, ACLs); use the fixed-size shortcut when the module documents a ceiling for the thing you are building.
Where to go next #
You now have the mechanics. From here, pick the subsystem you need:
- Access control (KACS) — the security vocabulary you just used, plus tokens, access checks, file security, and process security.
- The registry (LCS) — reading and writing Peios's configuration store.
- Events (KMES) — emitting and consuming the audit/event stream.
Using the SDK from Rust
Peios / Developing for Peios / SDK basics
The SDK is a C ABI, so it is reachable from any language with a C FFI — but Rust gets first-class, maintained bindings rather than hand-rolled extern blocks. If you are writing Rust on Peios, use them. This page is the on-ramp: what the crates are, how to wire them into a build, and where the docs live. It is deliberately not an API reference — that job belongs to the crate's rustdoc, which is generated from the code and so never drifts from it.
The two crates #
peios-rs is two crates, layered:
| Crate | What it is | Use it when |
|---|---|---|
peios-sys | Raw, unsafe FFI. Bindings are generated at build time by bindgen from the hand-written <peios.h> (the shipping API, which the ABI verifier proves is identical to the Rust source). | You need the raw C surface — an escape hatch, or to build your own abstractions. |
peios | The safe, idiomatic wrapper: RAII handle types, Result-returning methods, typed wire-constant families, and owned buffers — so you never touch a raw fd, a sticky-error builder, or a getxattr-style size probe directly. | Almost always. This is the crate you want. |
Both bind libpeios strictly as a C library through its stable ABI — they never depend on libpeios's internal Rust crates. The C ABI is the only supported entry point, and binding through it means the crates exercise the exact surface every other consumer uses.
The safe crate's modules mirror the libpeios concept headers one-for-one — security, token, access, file, process, event, msgpack, registry — so everything you learn about the C surface maps straight across.
Where the docs live #
Two places, and the split is deliberate:
-
The API reference is the crate's rustdoc. Every public item in
peiosis documented (the crate is built with#![warn(missing_docs)], so this is enforced), with a crate-level overview, per-module docs, and the error model. Build and browse it locally with:This is the reference precisely because it is generated from the code — it can never fall out of sync with the actual method signatures and types the way hand-written prose would.
-
The concepts live in these docs. The what and why — what a token is, how the two-call protocol works, how an access check reaches a verdict, the RSI source model — are identical whether you call from C or Rust, and they are documented once, here. The safe crate mirrors the concept headers, so each learn section maps to a module:
Rust module Concepts in security,token,access,file,processAccess control registryThe registry event,msgpackEvents Read the concept page for the model, then the rustdoc for the exact Rust API.
Adding the dependency #
Add the peios crate to your Cargo.toml:
[]
= { = "https://github.com/peios/peios-rs" }
(or a path/registry dependency, however you consume Peios crates). The features:
| Feature | Effect |
|---|---|
| default | Dynamic linking against libpeios.so — the intended production model (one soname-versioned system copy; fixes ship once). |
static | Static linking against libpeios.a. See the caveat below. |
uapi | Reuse the canonical Rust mirror of the pkm UAPI types (peios-uapi) instead of letting bindgen emit its own copy, so the kacs_*/pkm types are one identity across crates. |
A first taste #
The safe crate turns the C conventions into ordinary Rust. Compare this with the C first program — no size probes, no manual close, errors as Result:
use ;
The idiomatic wins show up most in the fiddly places. Impersonation, for instance, is an RAII guard — identity reverts when the guard drops, so it is exception-safe by construction rather than needing a manual revert in every cleanup path:
use BorrowedFd;
Success-side status values — a file's opened-vs-created disposition, a key's created-new-vs-opened — come back alongside the handle, not as errors; genuine failures are the Err(Error) side, where Error wraps the errno the C ABI set.
Linking: dynamic vs static #
This is the one Rust-specific wrinkle worth understanding, because the static path has a sharp edge.
- Dynamic (default) links
libpeios.so— the production model, and the path a normalstdRust program must use. - Static (
--features static) linkslibpeios.a. That archive is a Rust staticlib: it bakes in its own copy of the Rust runtime (thepanic = "abort"handler, the global-allocator shim, the alloc-error handler). Linking it into a consumer that also carries that runtime — i.e. any Rust binary that linksstd— collides on those symbols (rust_begin_unwind,__rust_alloc_error_handler, …). So the static path is for C consumers andno_stdRust binaries that supply no conflicting runtime. AstdRust consumer — includingcargo test— must use the dynamic path.
Keep that rule in mind and static linking is a non-issue: reach for it only from no_std, and use the default dynamic link everywhere else.
Finding libpeios at build time #
peios-sys's build script resolves the library and headers in this order:
- Environment override — set all three:
PEIOS_LIB_DIR— the directory containinglibpeios.{so,a}PEIOS_INCLUDE— the directory containing<peios.h>PKM_UAPI— the directory containing<pkm/*.h>(referenced bypeios.h's signatures)
- pkg-config — otherwise, if libpeios installs its
peios.pconPKG_CONFIG_PATH.
On a system where libpeios is installed from its packages, pkg-config just works and you set nothing. For a local checkout, point the three variables at your build tree:
PEIOS_LIB_DIR=../libpeios/target/release \
PEIOS_INCLUDE=../libpeios/include \
PKM_UAPI=../pkm/uapi \
One bring-up gotcha for local dynamic builds: the crate bakes an rpath that resolves libpeios by its soname (libpeios.so.0), but a plain cargo build of libpeios emits only the unversioned libpeios.so. Create the soname link once so the run resolves:
(This is a checkout convenience — on a real system the packaged libpeios.so.0 is already there.)
The bottom line #
- Depend on the
peioscrate; drop topeios-sysonly for the raw surface. - Concepts: these docs. API reference:
cargo doc -p peios --open. - Use the default dynamic link unless you are
no_std.
Everything the library conventions page teaches still holds underneath — the crate just wraps it in Rust idiom.
Access control overview
Peios / Developing for Peios / Access control
KACS — the Kernel Access Control Subsystem — is the heart of Peios's security model, and it is the part of the SDK you are most likely to reach for. This section is a tour: it explains how the pieces fit together and points you at the right guide (and the right reference page) for each task. If you have not yet read the library conventions, read those first — everything here assumes the error and buffer rules they describe.
The four nouns #
Almost everything in KACS is built from four things:
| Noun | What it is | SDK home |
|---|---|---|
| SID | The unique binary name of a principal — a user, group, machine, or well-known system actor. | security.h |
| Security descriptor (SD) | What protects an object: its owner, its group, and the ACLs that grant or deny access. Built from ACEs, each naming a SID and an access mask. | security.h |
| Token | The runtime object that carries an identity — a user SID, groups, privileges, an integrity level, claims. Every access decision is made against a token. A token is a file descriptor. | token.h |
| Access check | The act of deciding whether a token may perform a desired access on an object, given the object's SD. | access.h |
The relationship is simple to state: an access check asks whether a token (the subject) is granted some access to an object protected by a security descriptor, and both the token and the SD are expressed in terms of SIDs.
The shape of a decision #
When you need to make an authorisation decision in your own code, the pattern is almost always the same three steps:
- Get the subject's token. Usually the caller's — often via
peios_token_open_peeron a socket, so you learn who connected — or your own effective token (token_fd = -1). - Get the object's security descriptor. You either hold it already, read it off a file with
peios_file_get_sd, or build one with apeios_sd_builder. - Run the check.
peios_access_checktells you whether the desired rights are granted, and exactly which subset was granted.
The checking access guide walks that end to end.
Advisory versus enforced #
There is one distinction worth internalising early. The SDK's access check is advisory: it computes what the answer would be. It does not enforce anything — enforcement of a real operation (opening a file, adjusting a token) happens inside the kernel against the subject's own process security block.
So you use peios_access_check when your code is the resource manager: you own some object — a record in your database, a slot in your service — that isn't a kernel object, and you want to make the grant/deny decision using Peios identities and the same rules the kernel would apply. For actual kernel objects (files, tokens, registry keys), you don't pre-check and then act; you just act, and the kernel enforces, returning EACCES if denied.
Identity is not always your identity #
A recurring theme in KACS is acting as someone other than yourself:
- Impersonation lets a service temporarily adopt a caller's identity so its access checks run as them — the way a server does work "on behalf of" a client without running as root and hand-rolling permission logic. See working with tokens.
- Restricted tokens let you drop power — derive a strictly less-privileged token to hand to less-trusted code.
- Integrity levels and confinement bound what a token can touch regardless of its SIDs.
These are what make KACS more than POSIX uid/gid, and the token module is where you reach for them.
Where to go in this section #
- Working with tokens — who am I, who is calling me, and how to act as someone else.
- Checking access — building a security descriptor and making a decision end to end.
- Securing files — the native open and reading/writing file security descriptors.
- Hardening a process — turning on process mitigations.
For the exhaustive per-function detail behind any of these, the reference section documents every symbol.
Working with tokens
Peios / Developing for Peios / Access control
A token is the runtime carrier of an identity, and it is a file descriptor. This guide covers the everyday token tasks; token.h is the exhaustive reference for every call and field.
Who am I? #
To inspect your own identity, open your effective token and query it:
int tok = ;
if
unsigned char sid;
ssize_t n = ; /* the user SID */
uint32_t il;
; /* integrity level RID */
struct peios_privilege_set privs;
; /* held/enabled privileges */
;
peios_token_open_self gives you the effective token — if your thread is impersonating, that's the impersonated identity. Pass KACS_TOKEN_OPEN_REAL in the flags to get your process's real primary token regardless. The access argument is the handle rights you want; KACS_TOKEN_QUERY is enough to read.
Group SIDs and other list-valued classes come back as buffers you parse with the security.h views: read CLASS_GROUPS with peios_token_query, then peios_sid_array_parse it.
Who is calling me? #
The most useful token trick in a service is learning the identity of whoever connected to your socket. When a client connects over a Unix stream or seqpacket socket, KACS captures their token at connect() time, and you open it from the accepted connection:
int conn = ;
int caller = ; /* QUERY | IMPERSONATE rights */
if
/* Now query the caller's identity, or impersonate them (below). */
This is local authentication with no passwords and no handshake — the kernel vouches for who is on the other end. The handle comes with fixed QUERY | IMPERSONATE rights, which is exactly what a server needs.
Acting as the caller #
Once you hold a caller's (impersonation) token, you can impersonate them: adopt their identity on your current thread so every subsequent access check runs as them, not as your service. This is how you do work on a client's behalf without running privileged and re-implementing their permissions.
if
/* ... do the work here: file opens, access checks, etc. all run as the caller ... */
; /* back to your own identity */
;
Always pair peios_token_impersonate with peios_token_revert, ideally in the cleanup path, so a failure partway through can't leave your thread wearing someone else's identity. peios_token_revert is a safe no-op if you weren't impersonating.
The full flow for a request handler is: accept → peios_token_open_peer → peios_token_impersonate → serve the request → peios_token_revert → close.
Dropping power #
To run less-trusted code with less authority than you hold, derive a restricted token and hand it over. peios_token_restrict can delete privileges, demote groups to deny-only, and add restricting SIDs:
struct peios_token_restrict spec = ;
int weak = ;
The result is a strictly less-powerful token. Combined with integrity levels and confinement (both set when minting a token), this is the basis of sandboxing on Peios.
Minting tokens #
Creating a token from scratch requires SeCreateTokenPrivilege and is the province of authentication authorities, not ordinary programs. When you do need it, the token-spec builder is the ergonomic path — typed setters for the user SID, groups, privileges, integrity, claims, and the rest, then peios_token_builder_create. Mind the index convention for owner/primary-group references, and don't add the logon SID yourself — the kernel injects it.
Next #
- Checking access — use a token to make an authorisation decision.
token.hreference — every token call in full.- Impersonation — the operator-side model and its two gates.
Checking access
Peios / Developing for Peios / Access control
This guide walks a complete access decision: you have an object to protect, a caller to check, and you want KACS to tell you what they're allowed. The exhaustive detail lives in access.h and security.h; here we put them together.
When to use this #
Reach for peios_access_check when your program is the resource manager — you own something that isn't a kernel object (a document, an API route, a record) and you want to gate it with Peios identities and rules. For actual kernel objects, don't pre-check; just perform the operation and let the kernel enforce.
Remember the check is advisory: it computes the answer, you enforce it.
Step 1 — describe what protects the object #
An object is protected by a security descriptor. If you don't already have one, build it. Say the resource should be readable and writable by its owner and read-only for a "viewers" group:
/* An ACL: allow OWNER full, allow VIEWERS read. */
peios_acl_builder *acl = ;
;
;
size_t acl_len;
const void *acl_bytes = ;
/* Wrap it in a security descriptor with an owner. */
peios_sd_builder *sd = ;
;
;
size_t sd_len;
const void *sd_bytes = ;
(You can also write the descriptor as SDDL text and parse it — often easier when the policy is fixed.)
Step 2 — identify the subject #
The subject is a token. Most often it's the caller you opened from a socket, or your own effective token. In the request you pass either a token fd or -1 for your own effective token.
Step 3 — run the check #
Fill in a request and call. desired is what you're testing; mapping folds any generic rights to the object class's specific bits (use the class's published mapping — here we'll treat the object like a file):
struct peios_access_request req = ;
uint32_t granted = 0;
int rc = ;
Step 4 — interpret the result #
if else if else
The key idea: a denial is not an error to log and bail on — it's the expected "no". And because granted is filled even on denial, you can ask for a broad set of rights in one call and read back exactly which subset the subject has, rather than probing right by right. Clean up the builders (peios_acl_builder_free, peios_sd_builder_free) and the token fd when done.
Per-property checks #
If your object has properties or property-sets with their own object ACEs, evaluate the whole tree in one call with peios_access_check_list: supply an object-type tree and get one result per node, so you learn (for instance) that a caller may read most of an object but not one protected field — without a separate check per field.
Auditing a decision #
Pass a non-NULL peios_access_audit to learn what a SYSTEM_AUDIT ACE match would log, and whether a staged central access policy would decide differently — the signal you watch when rolling out a policy change.
Next #
access.hreference — every field of the request and the audit outputs.- Access decisions — the operator-side account of how KACS reaches a verdict (and how to debug a surprising denial).
Securing files
Peios / Developing for Peios / Access control
Peios files carry real security descriptors, and the SDK opens them with a native KACS open rather than POSIX open(). This guide covers the two everyday tasks: opening a file with a specific access, and reading or changing a file's security. The full surface is in file.h.
To keep the fragments readable, most error checks are elided here — every call below returns -1 with errno on failure, and real code must check each one (see Library conventions).
The native open #
peios_file_open is shaped like NtCreateFile: you state the access you want, what to do about existence (the disposition), any create options, and — when creating — the security descriptor to stamp on the new file. It returns an ordinary Linux fd whose granted access is fixed for the fd's lifetime, which means you can safely hand it to another process by SCM_RIGHTS, dup, or across exec: the fd carries exactly the access it was opened with.
Open-or-create a file, readable and writable, stamping a creator SD if it's new:
struct peios_open_params p = ;
uint32_t status = 0;
int fd = ;
if
if
else
status_out tells you what happened — created versus opened versus overwritten — without a separate stat and its attendant race. If you're only ever opening existing files, use a plain open disposition and pass sd = NULL.
Reading a file's security descriptor #
To see who can do what to a file, read its SD. secinfo selects which components you want — owner, group, DACL, SACL — so you fetch only what you need:
/* Probe, allocate, read (two-call). */
ssize_t need = ;
void *sd = ;
;
/* Parse it with a security.h view. */
peios_sd_view v;
;
peios_acl_view dacl;
if
;
If you already hold a file fd, use the fd-targeted peios_fd_get_sd instead of a path — no second path resolution, and for a normal file fd the check uses the access already baked in at open.
Changing a file's security descriptor #
Writing an SD is component-selective too: name the components you're changing in secinfo, and everything you don't name is preserved. To tighten a file's DACL without touching its owner or SACL:
/* Build an SD carrying only a DACL. */
peios_sd_builder *b = ;
;
size_t sd_len; const void *sd_bytes = ;
;
;
Because only KACS_SECINFO_DACL is selected, the owner, group, and SACL are left exactly as they were. The security.h builders are how you assemble the SD to apply.
Pre-flighting an open #
Sometimes you want to know whether a caller could open a file before you actually do. Read the file's SD, then run an access check against the caller's token with peios_file_generic_mapping — no open, no side effects, just the verdict.
Next #
file.hreference — every parameter, plus the fd-targeted calls and mount policy.- File access — the operator-side model of native file security.
Hardening a process
Peios / Developing for Peios / Access control
Peios lets a process opt into hardening — exploit mitigations enforced by the kernel on that process's security block. The SDK exposes this through a single call, peios_process_set_mitigations. This short guide covers using it well; the full flag set and semantics are in process.h and the operator docs.
Harden yourself at startup #
The common case is a program hardening itself early in main, before it processes any untrusted input:
int
pidfd == -1 targets the calling process. mitigations is a mask of KACS_MIT_* bits (from <pkm/psb.h>); combine the ones you want and set them together.
Three things to know #
It's one-way. Mitigation bits can only be set, never cleared — once on, they stay on for the life of the process. That's the point: a mitigation you could turn off is one an attacker could turn off. Treat each call as a permanent commitment.
It's all-or-nothing, and fails closed. If any requested protection can't actually be activated, the call changes nothing and returns -1. You never end up believing a mitigation is on when it isn't. So request the set you require together and check the result once — success means the whole set is active. (Bits from earlier successful calls stay on regardless.)
Targeting another process is privileged. To harden a process other than your own, you need PROCESS_SET_INFORMATION on it and PIP dominance over it — you can't reach into a process you don't already dominate. For self-hardening (-1), neither is needed.
Choosing what to enable #
The bit catalogue and what each mitigation defends against is documented operator-side under process mitigations (and in the Peios Kernel TRM §3.3, the Process Security Block). A couple of notes for the SDK caller:
KACS_MIT_ALLis the mask of all valid bits — useful for validating input, not usually what you'd blanket-enable without thought.KACS_MIT_CFIis a legacy alias that expands toKACS_MIT_CFIF | KACS_MIT_CFIB.
Enable the specific protections your program can tolerate, verify the call succeeded, and prefer to do it before you touch untrusted data.
Next #
process.hreference — the call in full.- Process mitigations — every mitigation and its threat model.
Registry overview
Peios / Developing for Peios / The registry
LCS — the Layered Configuration Subsystem — is Peios's registry: a kernel-mediated, hierarchical, secured configuration store. If you have used the Windows registry it will feel familiar — a tree of keys holding typed values — but LCS adds one defining idea: layers. This section teaches the client API; registry.h is the exhaustive reference.
Keys and values #
A key is a node in the tree. It has an immutable GUID identity, a KACS security descriptor that governs who can read or change it, and it holds values and child keys. You address a key by path and open it for specific KEY_* rights, getting back a key fd whose granted access is fixed for its lifetime.
A value is a named, typed piece of data on a key (REG_SZ, REG_DWORD, REG_BINARY, and the rest). An empty name is the key's default value.
Layers and precedence — the LCS idea #
Here is what makes LCS more than a key/value store. Every value write is tagged with a layer, and layers have a fixed precedence order. When you read a value, LCS resolves the effective entry — the one from the highest-precedence layer that has something to say — and that's what you get back.
This is what lets configuration compose cleanly:
- A base layer ships default configuration.
- A site or policy layer overlays organisation-wide settings.
- A machine-local layer overrides per-host.
They all coexist on the same key. Reading gives you the winner; writing targets a specific layer (or the base layer by default). And because it's layers rather than destructive overwrites, removing a higher layer's entry lets the lower one re-emerge — you can override and then un-override without losing the original.
Tombstones are the tool for "hide, don't delete": a per-value tombstone masks lower layers for one value, and a blanket tombstone masks all lower values of a key on a layer at once. Keys have the same idea via hide.
Reads report where the answer came from #
Because a value can come from any layer, the read tells you which layer won and gives you a sequence number for the effective entry. That sequence number is the basis of safe updates: pass it back as a compare-and-swap guard on a write, and the write only lands if nothing changed underneath you.
Transactions #
Mutating operations — creating keys, setting and deleting values — can be grouped into a transaction and committed atomically, so a multi-step configuration change either fully applies or not at all. Transactions are abort-by-default: close the transaction fd without committing and nothing happens. See watching and transactions.
Watches #
You can ask a key to notify you when it changes — values, subkeys, or its security — optionally across its whole subtree. The elegant part: once armed, the key fd itself becomes pollable, so a registry watch drops straight into an epoll loop with no side channel.
The client, not the source #
This SDK is the registry client — it reads and writes the store. It does not implement a registry source (a storage backend); that's a separate library, librsi. As a client you speak only the calls in registry.h.
Where to go in this section #
- Reading and writing — open a key, read effective values, write to layers, do safe updates.
- Watching and transactions — react to changes and apply atomic multi-step edits.
registry.hreference — every call in full.
Reading and writing
Peios / Developing for Peios / The registry
This guide covers the bread-and-butter registry operations. The full call signatures and error sets are in registry.h; here we string them together. Recall the registry's descriptor-struct buffer convention: reads fill *_cap/*_len fields and a zero-capacity buffer probes.
The fragments below elide some error checks for brevity — every call returns -1 with errno on failure, and real code must check each one (see Library conventions).
Opening a key #
Open a key for the rights you need:
int key = ;
if
parent_fd == -1 means path is absolute. To open relative to a key you already hold, pass that key fd as the parent. Use peios_reg_create_key instead when the key might not exist yet — it opens-or-creates and reports which happened.
Reading the effective value #
Reading resolves layer precedence for you and hands back the winning value, its type, and which layer it came from:
struct peios_reg_value v = ;
unsigned char data;
v.data = data; v.data_cap = sizeof data;
/* leave v.layer NULL if you don't care which layer won */
if else if else if
The name_len is explicit (value names are length-counted; 0 reads the key's default value). Pass a transaction fd as the fourth argument to read within a transaction, or -1 for none.
To read every value at once, use peios_reg_query_values_batch — one call fills a buffer with all effective values in a packed record format. To walk them one at a time, loop peios_reg_enum_value from index 0 until ENOENT.
Writing a value #
Writes target a specific layer. Pass NULL/0 for the layer to write the base layer:
uint32_t timeout = 30;
int rc = ; /* no CAS guard */
Writing to a higher-precedence layer overrides lower ones without destroying them; deleting that layer's entry later lets the lower value re-emerge.
Safe updates with compare-and-swap #
To read-modify-write without clobbering a concurrent change, feed the sequence you read back as the expected_seq guard on the write. The write only lands if nothing changed underneath you; otherwise it fails with EAGAIN and you retry:
for
Passing expected_seq == 0 disables the guard (an unconditional write).
Cleaning up #
Close key fds with close() when done. Values you wrote with auto-commit (txn_fd == -1) are already durable to the layer; to force the source to persist a hive's pending writes at a known point, call peios_reg_flush.
Next #
- Watching and transactions — react to changes and batch atomic edits.
registry.hreference — every call, field, and error.
Watching and transactions
Peios / Developing for Peios / The registry
Beyond simple reads and writes, LCS gives you two higher-order tools: watches to react to change, and transactions to apply several edits atomically. Both are in registry.h; this guide shows the shape of using them. To keep that shape visible, the fragments elide most error checks — every call returns -1 with errno on failure, and real code must check each one (see Library conventions).
Watching for changes #
Ask a key to notify you when it changes, then poll its fd. Because the armed key fd is itself pollable, a watch integrates directly into whatever event loop you already run:
int key = ;
/* Watch values and subkeys, across the whole subtree. */
;
struct pollfd pfd = ;
for
filter is a mask of REG_NOTIFY_VALUE, REG_NOTIFY_SUBKEY, and REG_NOTIFY_SD; REG_NOTIFY_ALL covers all three. The subtree flag extends the watch to descendants. Arming needs KEY_NOTIFY on the key. Call peios_reg_notify(key, 0, 0) to disarm.
Each read() drains as many complete change records as fit — every record starts [total_len: u32][event_type: u16][name_len: u16][name], so you step through the buffer by total_len. The full layout, the REG_WATCH_* event types, and the extra path fields a subtree watch appends are in the reference. Two practical notes: a buffer too small for even one record fails EINVAL, so size it generously rather than exactly; and a REG_WATCH_OVERFLOW record means events were dropped — re-read the key's state instead of trusting the stream.
This is how a service picks up configuration changes live — no polling loop re-reading values on a timer, just a blocking poll that wakes when something actually changed.
Transactions #
When a configuration change spans several operations — create a key, set a few values, delete a stale one — you usually want it to be all-or-nothing. That's a transaction.
Begin one, pass its fd as the txn_fd argument to each operation you want enlisted, then commit:
int txn = ;
int key = ;
uint32_t one = 1;
;
;
int rc = ;
if else if
;
;
Two things to keep in mind:
- Abort is the default. If you close the transaction fd without committing — including on any early-return error path — nothing is applied. So you don't need explicit rollback logic; just don't commit.
- Commit can be retried.
EBUSY(write-lock contention) andEIO(source failure) leave the transaction active, so you can retrypeios_reg_commit. Only0(committed — the fd is now terminal) andEINVAL(already committed or never bound) are final. Check state at any point withpeios_reg_txn_status.
Backup and restore #
To snapshot a key and its whole subtree, or replace one from a snapshot, use peios_reg_backup and peios_reg_restore. They stream to and from an fd and are gated by SeBackupPrivilege / SeRestorePrivilege; restore applies in a single transaction.
Next #
registry.hreference — the notify filters, transaction states, and every error in full.- Reading and writing — the value operations you enlist in a transaction.
Events overview
Peios / Developing for Peios / Events
KMES is Peios's event system, and it is the sole event path on the system. Audit records, subsystem events, and your own application events all travel the same way: the kernel stamps each event with trusted metadata and writes it into a per-CPU, lock-free ring buffer, and consumers drain those rings. This section teaches both sides — producing and consuming — via event.h and msgpack.h.
What an event is #
An event has two parts:
- Kernel-stamped metadata you cannot forge — a
CLOCK_REALTIMEtimestamp, a per-CPU monotonic sequence number, the CPU id, an origin class, and identity GUIDs (the effective token, the true token, and the process). This is the trustworthy skeleton: when you consume an event, you know who emitted it and when, because the kernel wrote that, not the emitter. - A payload — a single MessagePack value that you define. This is your event's actual content.
The event_type is a short UTF-8 string you choose, like "my.app.login", that names the kind of event.
Payloads are MessagePack, and you own them #
The kernel does not build or interpret payloads — it only structurally validates them on emit (one well-formed MessagePack value, within size and nesting limits). So userspace owns encoding and decoding, and the SDK ships a MessagePack codec whose validator's acceptance is matched to the kernel's check. Build a payload with the writer, and a successful peios_mp_writer_bytes (or peios_mp_validate) means the emit call will accept it.
Per-CPU rings and lost events #
Events live in per-CPU ring buffers — one ring per logical CPU, lock-free so producers never block on consumers. Two consequences shape how you consume:
- You drain per CPU. To see everything, run a reader per CPU (discover the count by attaching upward from CPU 0 until it fails).
- Rings can lap. If you don't drain fast enough, new events overwrite old ones you haven't read. The per-CPU
sequencenumbers are contiguous, so a gap in the sequence means events were lost — and the reader tracks that count for you.
Privileges #
The two sides are gated separately:
- Emitting requires
SeAuditPrivilege. - Consuming (attaching to a ring) requires
SeSecurityPrivilege.
Two ways to consume #
The SDK offers a high-level reader that hides the entire lock-free drain — barriers, lapping recovery, lost-event accounting, buffer-resize handling, and the wait — behind a simple next/wait loop. That's what almost everyone should use. There is also a low-level ring API for callers who need to drive the drain inside their own event loop. Both are covered in consuming events.
Where to go in this section #
- Emitting events — build a payload and emit, singly or in batches.
- Consuming events — drain the rings with the high-level reader (and, briefly, the low-level ring).
event.handmsgpack.h— the exhaustive reference.
Emitting events
Peios / Developing for Peios / Events
Emitting an event is two steps: build a MessagePack payload, then hand it and an event type to the kernel. This guide shows both; event.h and msgpack.h are the full references. Emitting requires SeAuditPrivilege.
Build the payload #
Use the MessagePack writer to encode a single top-level value — typically a map of fields:
peios_mp_writer *w = ;
; /* {"user":…, "ok":…} */
; ;
; ;
const void *payload;
ssize_t plen = ; /* validates as it borrows */
if
peios_mp_writer_bytes validates that what you built is exactly one well-formed value, so a non-negative return means the payload is emit-ready. (Remember a map of n needs 2*n values — one per key and value.)
Emit it #
int rc = ;
;
if
The event type is length-counted UTF-8 and must be non-zero length ("my.app.login" is 12 bytes — not NUL-terminated on the wire). On success the kernel stamps the trusted metadata (timestamp, sequence, identity GUIDs) and sets origin_class = userspace; you don't provide any of that.
Validating untrusted payloads first #
If a payload's shape comes from dynamic or untrusted input, validate it in userspace before emitting so you handle the failure on your terms rather than as an EINVAL from the kernel:
if
The validator's acceptance matches the kernel's emit-time check at that depth bound.
Emitting in batches #
A high-rate producer should batch. peios_event_emit_batch emits many events in one call, so a single timestamp capture, identity capture, and consumer wake cover the whole set:
struct peios_event_entry entries = ;
uint32_t emitted = 0;
int rc = ;
if
count must be in [1, KMES_BATCH_MAX_ENTRIES]. On failure, errno is the reason the first failing entry failed and emitted tells you how many succeeded before it, so you know exactly where to resume. One caveat: rate-limiting is all-or-nothing for a batch — an EAGAIN emits none of it, so on EAGAIN back off and retry the whole batch.
Next #
- Consuming events — the other side of the pipe.
msgpack.hreference — the full encoder, including containers, extensions, and raw splicing.
Consuming events
Peios / Developing for Peios / Events
Consuming events means draining the per-CPU ring buffers. The SDK's high-level reader hides all the hard parts, so most consumers are a short loop. This guide shows that loop and how to parse what you read; event.h has the full API. Consuming requires SeSecurityPrivilege.
The reader loop #
Open a reader for a CPU, then loop next/wait:
peios_event_reader *r = ;
if
for
;
peios_event_reader_next returns 1 (event filled), 0 (nothing available — call wait), or -1 (error). peios_event_reader_wait blocks until events arrive or the timeout elapses (negative = forever). The reader handles the memory barriers, lapping recovery, buffer-resize handling, and futex wait internally — you just alternate the two calls.
Parsing an event #
Each struct peios_event gives you the trusted metadata by value and the payload as a MessagePack value. Parse it with a reader:
void
The lifetime rule #
ev->event_type and ev->payload point into the ring mapping. They are valid only until your next peios_event_reader_next call, and only while the slot hasn't been overwritten. So copy out anything you need to keep before continuing the loop — don't stash the raw pointers.
Draining every CPU #
Rings are per-CPU, so one reader sees only one CPU's events. To consume the whole machine, run a reader per CPU — typically one thread each. Discover the CPU count by attaching upward until it fails:
uint32_t ncpu = 0;
for
/* now spawn one peios_event_reader per cpu in [0, ncpu) */
Watching for loss #
If a consumer falls behind, the producer laps it and events are lost. Poll peios_event_reader_lost to see the cumulative count of lost events (derived from sequence gaps). A rising number means you aren't draining fast enough — process events more cheaply, hand off to a worker, or accept the loss deliberately.
The low-level ring #
If the reader's loop doesn't fit your event model — you want the ring integrated into an existing epoll/state-machine loop, driving the read position yourself — the low-level ring API exposes the mapping directly: peios_event_ring_map, the position accessors (write_pos/tail_pos/generation), peios_event_ring_event_at to parse a slot, and peios_event_ring_wait to sleep. It's more bookkeeping (you own the read position and the empty/lapping/generation checks) for more control. Reach for it only when you need to; the high-level reader is the right default.
Next #
event.hreference — the full reader and ring APIs, and everystruct peios_eventfield.- Auditing — the operator-side view of the event and audit stream.
Registry sources overview
Peios / Developing for Peios / Registry sources
A registry source is a storage backend for the LCS registry. It is the other half of the registry story: where a client opens keys and reads values, a source is what actually holds those keys and values and answers the kernel when it needs them. If you are implementing a place for registry data to live — a file-backed store, a database adapter, an in-memory provider for tests — you are writing a source, and librsi is the library for it.
This is a different library from libpeios, for a different audience. libpeios is the registry client; librsi is the registry source. They share the SDK's conventions and substrate, but you link librsi (-lrsi, <rsi.h>) to write a source.
How it works #
A source talks to the kernel over the RSI protocol — the Registry Source Interface — on a single file descriptor:
flowchart LR
client[Registry client<br/>libpeios] -->|syscalls| kernel[LCS in the kernel]
kernel <-->|RSI framed protocol<br/>over the source fd| source[Your source<br/>librsi]
The flow is:
- Register. Your process declares which hives (registry subtrees) it backs and registers with the kernel, receiving a source fd. This needs
SeTcbPrivilege. - Serve. The kernel sends your source requests — "look up this child", "store this value", "begin this transaction" — as framed messages you
read(2)from the source fd. You decode each one, do the work against your storage, andwrite(2)back a framed response. - Repeat until the source fd reaches EOF (the source is closing).
When a client reads or writes a key in one of your hives, the kernel turns that into RSI requests to you. Your source is the source of truth; the kernel mediates, enforces security, and resolves layer precedence on top of what you report.
The serve loop #
Every source is, at heart, this loop:
for
The three headers map onto the three steps:
<rsi/source.h>— registering and getting the source fd.<rsi/request.h>— reading and decoding requests.<rsi/response.h>— building and sending responses.
What the kernel handles, and what you handle #
The division of labour matters, because it keeps a source simple:
- The kernel handles security (access checks against key SDs), layer precedence resolution, transaction coordination across sources, and the client-facing syscall surface. It also assigns the global sequence numbers.
- Your source handles durable storage: it stores the name→GUID entries, the key metadata records, and the layered values, and it reports them back faithfully when asked. It honours transaction boundaries (buffer, then commit or abort) and compare-and-swap guards on writes.
You do not implement precedence, access control, or the client protocol — you store data and answer questions about it. That's why a source's serve loop is mostly a dispatch table over storage operations.
Requests come in families #
The request reference groups the operations, and it helps to hold the shape in mind:
- Path/entry ops — the name→GUID hierarchy (
LOOKUP,CREATE_ENTRY,HIDE_ENTRY,DELETE_ENTRY,ENUM_CHILDREN). - Key ops — key metadata records (
CREATE_KEY,READ_KEY,DROP_KEY,WRITE_KEY). - Value ops — the typed values on a key (
QUERY_VALUES,SET_VALUE,DELETE_VALUE_ENTRY,SET_BLANKET_TOMBSTONE). - Transaction ops — atomic grouping (
BEGIN/COMMIT/ABORT_TRANSACTION). - Layer ops —
DELETE_LAYER,FLUSH.
Most reply with a simple status; five carry data back on success.
Where to go in this section #
- Registering a source — declare your hives and get the source fd.
- Serving requests — the read/parse/dispatch/decode loop in full.
- Building responses — status-only and payload-bearing replies.
Registering a source
Peios / Developing for Peios / Registry sources
Every source starts by registering. You tell the kernel which hives your process backs, and it hands you a source fd to serve on. The full detail is in rsi/source.h; this guide walks the decisions.
Declare your hives #
A hive is a registry subtree with its own root key. Fill in one struct rsi_hive per hive your source backs:
struct rsi_hive hive = ;
The two decisions per hive:
- Global or private? A global hive (
flags = 0,scope_guidzero) is visible system-wide. A private hive (flags = RSI_HIVE_PRIVATE,scope_guidnon-zero) is scoped — only tokens carrying that scope GUID in their LCS credentials can resolve it. Use a private hive for per-application or per-tenant state that shouldn't be system-visible. - The root GUID. Every hive is anchored at a root key identified by
root_guid. This is the GUID paths in the hive resolve from, and it must match the root your storage actually holds.
Register #
Hand the kernel your hive array and the highest sequence number you've already persisted:
int src = ;
if
/* `src` is the source fd — serve the RSI protocol on it. */
Two things to get right:
SeTcbPrivilegeis required. Registration is a trusted operation; without the privilege you getEPERM. Sources run as trusted system components.max_sequenceis your durability contract. It is the highest sequence number this source has already persisted. The kernel resumes its global sequence counter past it, so it never hands out a number you've used. A brand-new source with no stored state passes0; a source restarting with durable data must scan that data for the highest sequence it ever wrote and pass that. Getting this wrong risks sequence reuse, so make it the first thing your restart path computes.
You can register several hives in one call by passing an array and a count greater than one; the kernel enforces its configured MaxHivesPerSource limit (ENOSPC if you exceed it).
After registration #
The returned fd is your serve endpoint — you read(2) requests and write(2) responses on it directly (via the request and response helpers). Closing it deregisters the source and signals EOF to the kernel. Keep it open for the life of the source, and move on to the serve loop.
Next #
- Serving requests — the loop that runs on the source fd.
rsi/source.hreference — every field and error.
Serving requests
Peios / Developing for Peios / Registry sources
With a source fd in hand, a source spends its life in one loop: read a request, decode it, do the work, reply. This guide builds that loop. The exhaustive per-op detail is in rsi/request.h and rsi/response.h.
The loop skeleton #
unsigned char buf; /* size generously — a short buffer is EMSGSIZE */
for
Three details in that skeleton matter:
rsi_read_requestblocks until a request is queued, and returns0at EOF — that's your exit. Sizebufgenerously; a frame larger thanbufreturns-1/EMSGSIZE.rsi_parse_requestgives youreq.op_codeto dispatch on, plusreq.request_id(which every reply must echo — the helpers do this for you) andreq.txn_id(nonzero when the request is inside a transaction).- Always reply. Every request expects exactly one response. If you can't handle an op, reply with a non-OK status rather than dropping it.
Decoding a request #
Inside a handler, decode the payload with the matching rsi_request_* parser. For a LOOKUP:
void
And for a SET_VALUE, a status-only op:
void
The borrow rule #
Every decoded name and data field — q.child_name, v.value_name, v.data, and the rest — is a (ptr, len) pair that points into buf. Those pointers are valid only until the next rsi_read_request reuses the buffer. So:
- Consume them within the handler (store the bytes, resolve the name) before the loop comes around again, or
- Copy out anything you need to keep. Never stash a raw borrowed pointer across iterations.
GUIDs and scalars in the decoded struct are copied by value, so those are always safe to keep — it's only the borrowed (ptr, len) fields that have the lifetime.
Transactions #
When the kernel sends BEGIN_TRANSACTION, buffer subsequent writes tagged with that transaction_id instead of applying them; req.txn_id on each later request tells you which transaction it belongs to (0 = none). On COMMIT_TRANSACTION apply the buffered set atomically; on ABORT_TRANSACTION discard it. All three are status-only replies. The kernel coordinates transaction boundaries — your job is to buffer, then commit or discard on command.
Next #
- Building responses — choosing and filling the right reply.
rsi/request.hreference — every op's decoder and struct.
Building responses
Peios / Developing for Peios / Registry sources
Every request gets exactly one response. Choosing the right one is simple once you know the rule; filling it in is a matter of handing librsi flat arrays and letting it encode the frame. The full contract is in rsi/response.h; this guide is the working version.
The rule #
On failure, always
rsi_respond_status. On success,rsi_respond_statustoo — unless the op is one of the five that carry a payload.
Most operations (SET_VALUE, CREATE_KEY, the transaction ops, FLUSH, …) are status-only in both cases. And any op reports a non-OK outcome with rsi_respond_status, whatever it is:
/* Success for a status-only op: */
;
/* Any op reporting a problem: */
; /* e.g. a missing key */
; /* a failed CAS */
You never build the frame or echo the request id yourself — the helper reads what it needs from req and writes the framed reply to fd.
The five payload-bearing ops #
Exactly five ops return data on success, each with its own helper:
| Op | Helper | You supply |
|---|---|---|
LOOKUP | rsi_respond_lookup | path entries + the referenced keys' metadata |
ENUM_CHILDREN | rsi_respond_enum_children | children (name + path entries) + metadata |
READ_KEY | rsi_respond_read_key | one key's non-layered metadata |
QUERY_VALUES | rsi_respond_query_values | value entries + blanket tombstones |
DELETE_LAYER | rsi_respond_delete_layer | the orphaned keys' GUIDs |
You pass the result as flat arrays; librsi validates and heap-encodes the wire frame. A QUERY_VALUES reply, for example:
struct rsi_value_entry values = ;
;
You report what you store, per layer; the kernel resolves precedence across the layers you return. (Note the (NULL, 0) for the empty blanket array — a NULL pointer is allowed only when its count is zero.)
The validation contract #
The helpers check their inputs before encoding and return -1/EINVAL if you break the contract — better a caught programming error than a malformed frame on the wire. The rules that apply across all of them:
(ptr, len)pairs: a pointer may beNULLonly when its length/count is zero.- Booleans (
volatile_key,symlink, target types) are strictly0or1. - Hidden path targets (
RSI_PATH_TARGET_HIDDEN) carry an all-zerotarget_guid. LOOKUP/ENUM_CHILDRENmetadata must exactly cover the GUID targets in your path entries — every referenced key present, none missing, no duplicates, nothing unreferenced.DELETE_LAYERorphan GUIDs are nonzero and unique.
Beyond EINVAL, a helper can also fail with ENOMEM or EOVERFLOW (building the frame), EIO (a short write), or the raw write(2) errno — treat those as you would any I/O failure on the source fd.
Putting it together #
A source's handler for a payload-bearing op is: decode the request, gather the result from storage into the flat arrays the helper wants, call the helper. For a status-only op: decode, do the work, rsi_respond_status. On any error along the way — a decode failure, a missing key, an I/O problem — rsi_respond_status with the appropriate non-OK RSI_* code. That uniformity is what keeps a source's dispatch table readable no matter how many ops it supports.
Next #
rsi/response.hreference — every helper, struct, and error in full.- Serving requests — the loop these replies live in.
1.1 Library conventions
Peios / Developing for Peios / SDK Reference / Library Conventions
libpeios has a small number of conventions that hold across every function in every module. They are deliberately uniform: once you know how one function reports an error or returns a variable-length buffer, you know how all of them do. This page is the one to read slowly. Everything else in this documentation assumes it.
The conventions come in four groups: how results are returned, the two-call buffer protocol, memory ownership (builders and views), and the small stuff (file descriptors and constants).
1.2 How results are returned
Peios / Developing for Peios / SDK Reference / Library Conventions
Every entry point reports success or failure through its return type. There are three return shapes, and the shape tells you how to read the result.
1.2.0.1 int — a file descriptor, or zero #
A function returning int returns either:
- a file descriptor (a non-negative
int), when its job is to open something — a token, a registry key, an event stream; or 0on success, when it performs an action with no handle to hand back; and-1on failure, with the reason inerrno.
int fd = ;
if
1.2.0.2 ssize_t — a byte length #
A function returning ssize_t produces a variable-length result — a SID, a serialised security descriptor, a formatted string, a registry value. It returns:
- the length in bytes of the result on success (
>= 0); or -1on failure, with the reason inerrno.
These are the functions that use the two-call buffer protocol below. The returned length is always the full length of the result, which is what makes the protocol work.
For functions that format a string, the returned length excludes the terminating NUL — exactly like snprintf. So a return of 41 means "41 characters plus a NUL"; size your buffer as len + 1.
1.2.0.3 Structured results — out-parameters #
When a call produces more than one value, or a value that isn't naturally a length or an fd, it writes through out-parameters and returns int (0 / -1). The access check is the archetype: it returns 0 when access is granted and -1 with errno == EACCES when it is denied, and it writes the granted access mask through an out-parameter either way.
uint32_t granted = 0;
int rc = ;
/* rc == 0: granted; rc == -1 && errno == EACCES: denied.
`granted` is populated in both cases. */
A denial is a normal, expected outcome, not a bug — which is why it is reported the same disciplined way as any other errno, rather than through a separate channel.
1.2.0.4 errno #
Failure is always reported through the standard C errno. The library sets errno on every -1 return and uses ordinary, portable errno values — there are no libpeios-specific or PKM-specific error numbers to learn. The ones you will see most:
| errno | Meaning in libpeios |
|---|---|
EINVAL | Malformed input — a bad SID, an unparseable SDDL string, an argument out of range. |
ERANGE | Your output buffer was non-zero but too small. Nothing was written. (See the protocol below.) |
EACCES | An access check denied the request. |
ENOMEM | An allocation failed (for the heap-backed builders). |
EBADF, ESRCH, EFAULT | The usual Linux meanings — a bad fd or pidfd, a vanished process, a bad pointer. |
Because the values are standard, strerror, perror, and your language's normal errno handling all work unchanged. Check the return value first, then read errno — like any POSIX call, errno is only meaningful after a call that signalled failure.
Nothing ever unwinds across the boundary. The library is compiled to abort rather than propagate a panic through the C ABI, so a call either returns a value you can inspect or the process dies — it never leaves you with a corrupt half-state to reason about.
1.3 The two-call buffer protocol
Peios / Developing for Peios / SDK Reference / Library Conventions
Every function that returns variable-length bytes — anything with an ssize_t return and an (out, cap) pair — follows the same getxattr-style protocol. It is the single most important convention in the library, so it is worth internalising.
The rule:
- Call with
cap == 0(or aNULLbuffer) to probe: the function writes nothing and returns the number of bytes the result needs. - Call with a buffer of at least that size to retrieve: the function fills the buffer and returns the number of bytes it wrote.
- Call with a non-zero but too-small buffer and it fails with
ERANGEand writes nothing — never a truncated or partial result.
That last point is the safety property that makes the protocol trustworthy: a too-small buffer is a clean, detectable error, not a silent truncation. You never have to wonder whether you got the whole thing.
The canonical two-call sequence:
/* 1. Probe for the size. */
ssize_t need = ;
if
/* 2. Allocate. For a string, add 1 for the NUL. */
char *buf = ;
/* 3. Retrieve. */
ssize_t n = ;
if
/* buf now holds the formatted SID; n is its length (excluding the NUL). */
When you already know a comfortable upper bound, you can skip the probe and call once with a big-enough buffer. Some results have a fixed maximum the library gives you a constant for — for example a SID is never larger than PEIOS_SID_MAX_BYTES, so a stack buffer of that size always fits and never needs a probe. Those shortcuts are called out where they apply; the two-call protocol is always available as the general fallback.
1.4 Memory ownership
Peios / Developing for Peios / SDK Reference / Library Conventions
libpeios never hands you an allocation to free(). Instead it uses two ownership patterns — builders for constructing byte buffers and views for reading them — and both keep the memory question simple: you own your buffers, the library borrows or copies, and the two never get confused.
1.4.0.1 Builders — constructing buffers #
Anything you assemble (an ACL, a security descriptor, a token specification) is built with a builder: an opaque, heap-backed object you create, feed, take the bytes from, and free.
Builders have three properties worth knowing up front:
-
They are sticky-error. The incremental
add/setcalls returnvoid— they never fail inline. If one hits a problem (a bad input, an allocation failure), the builder latches the error and every later call is a no-op. You do not have to check each step. Instead you check once, at the end: either call the builder's_error()accessor (it returns the latched errno, or0if all is well), or notice that taking the bytes fails. This lets you write a long, clean sequence ofaddcalls without a conditional after every line. -
You free every builder you create. Each
_new()is paired with a_free(). Builders also have a_reset()that drops the accumulated content and clears the sticky error, so you can reuse one builder across several objects instead of churning allocations. -
Taking the bytes: borrow (and sometimes copy). Every builder has a
_bytes()that hands back a pointer into the builder — zero-copy, no allocation. That pointer is valid only until the next mutating call,_reset(), or_free()on that builder. Use it when you are going to consume the bytes immediately (for instance, pass them straight into a kernel call). The call comes in two shapes, and not every builder offers a copying counterpart:- The security builders (
peios_acl_builder_bytes,peios_sd_builder_bytes) return the pointer —NULLif the sticky error is set — and write the length through an optionallen_outpointer. Each is paired with a_finish()that copies the buffer into a caller-supplied buffer using the two-call protocol above, for when the bytes must outlive the builder. peios_token_builder_bytesandpeios_mp_writer_bytesare shaped the other way round: they return the length as anssize_t(-1witherrnoon a latched error) and write the borrowed pointer through an out-parameter (which may beNULLto get just the length). Neither has a_finish()— copy the borrowed bytes yourself if they need to outlive the builder.
- The security builders (
A typical builder lifecycle:
peios_acl_builder *b = ; /* NULL on OOM */
; /* void — no check */
;
size_t len;
const void *acl = ; /* NULL if errored */
if
/* … use `acl` before the next mutation … */
;
1.4.0.2 Views — reading buffers #
Anything you parse (a security descriptor, an ACL, a SID array from a token) is read through a view: a small, caller-allocated struct that you point at a buffer you already hold.
Views have their own two rules:
-
You allocate the view; it is stack-friendly. A view type such as
peios_sd_viewis an opaque fixed-size struct — you declare one as a local variable and pass its address to the parse call. No heap, no free. The struct's fields are opaque: never read them directly; use the accessor functions. -
A view borrows the buffer it parses — zero-copy. The parse call does not copy the data; the view points into your buffer, and every accessor that yields a SID, a nested ACL, or a blob hands back a pointer into that same buffer. So the buffer must stay alive and unmodified for as long as the view — and anything you derived from it — is in use. Free or mutate the underlying buffer and every pointer the view gave you dangles.
peios_sd_view sd; /* on the stack */
if
const void *owner; size_t owner_len;
if
Views compose: parsing a security descriptor gives you a peios_sd_view, from which you obtain a peios_acl_view for its DACL, from which you obtain each peios_ace_view. Every one of them borrows the same original buffer, so keeping that one buffer alive keeps the whole tree valid.
The symmetry is the thing to remember: builders own heap and must be freed; views own nothing and borrow your buffer. Constructing is builders, reading is views, and neither ever asks you to free something the library allocated.
1.5 File descriptors
Peios / Developing for Peios / SDK Reference / Library Conventions
Handles that libpeios opens — tokens, registry keys, event streams — are raw int file descriptors, the same kind open() gives you. You close them with close(), poll them, and pass them across exec (or not) with the usual fd machinery.
They are created O_CLOEXEC by default: a handle does not leak across an exec unless you deliberately clear the flag with fcntl. This is the safe default for security-sensitive handles — a token or key fd will not silently end up in a child process you launch.
1.6 Constants
Peios / Developing for Peios / SDK Reference / Library Conventions
libpeios does not invent its own names for the kernel's wire constants. The access-right bits, ACE types, control flags, and mapping structs all come straight from the <pkm/*.h> UAPI headers, and you use those published names directly: KACS_ACCESS_*, KACS_ACE_TYPE_*, KACS_SD_*, struct kacs_generic_mapping, and so on. There is no parallel PEIOS_* aliasing to translate in your head — the name in the PSD, the name in the kernel header, and the name you write in your code are the same name.
The handful of constants that are libpeios's own — buffer-size ceilings like PEIOS_SID_MAX_BYTES, and enums for convenience selectors like enum peios_wks (well-known SIDs) — are prefixed PEIOS_ and documented with the module that defines them.
1.7 The conventions at a glance
Peios / Developing for Peios / SDK Reference / Library Conventions
| Convention | The rule |
|---|---|
int return | fd or 0 on success; -1 + errno on failure. |
ssize_t return | byte length on success; -1 + errno on failure. Strings exclude the NUL. |
| Two-call protocol | cap == 0 / NULL probes for the size; too-small non-zero buffer → ERANGE, nothing written. |
| errno | standard values only; check the return first, then errno. |
| Access denial | -1 + EACCES, with the granted mask still written to the out-param. |
| Builders | heap-backed, sticky-error, void adders; check _error() at the end; _free() every one; _bytes() borrows, and the security builders add a _finish() that copies. |
| Views | caller-allocated (stack), opaque, borrow the parsed buffer; keep that buffer alive and unmodified. |
| File descriptors | raw int, O_CLOEXEC by default, closed with close(). |
| Constants | use the <pkm/*.h> KACS_* names directly; only libpeios's own additions are PEIOS_*. |
With these in hand, the module documentation reads as just "what does this function do?" — the how of memory and errors is answered here, once, for all of them. Next: your first program, which puts the protocol and the error model to work in something you can compile.
2.1 security.h — Security descriptors
Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors
<peios/security.h> is the shared vocabulary of the whole access-control surface. SIDs, security descriptors, ACLs, and ACEs are the currency every KACS interface trades in — tokens carry them, files are protected by them, access checks evaluate them, and the registry secures keys with them. They cross the kernel boundary as variable-length, self-relative byte buffers in the MS-DTYP wire formats, and this module is the one place libpeios lifts that raw wire form into something safe to handle from C.
Everything here assumes the library conventions: ssize_t returns are byte lengths using the two-call protocol, builders are heap-backed and sticky-error, and views borrow the buffer they parse. This page does not repeat those rules per function — read that page first.
The module has four parts:
- SIDs — build, parse, format, and compare security identifiers.
- ACLs and security descriptors — assemble them with builders.
- Parsing — read them back with zero-copy views.
- SDDL and inheritance — the text form and the userspace-only inheritance helpers.
The wire constants (KACS_SID_*, KACS_SD_*, KACS_ACE_*, and struct kacs_generic_mapping) come straight from <pkm/sid.h> and <pkm/sd.h>. libpeios does not re-alias them — you use the published ABI names directly.
2.1.1 See also #
- Library conventions — the error, buffer, builder, and view rules this page builds on.
- SIDs and Security descriptors — the operator-side concepts behind this vocabulary.
<peios/token.h>,<peios/file.h>,<peios/access.h>— the KACS interfaces that consume this vocabulary, including the generic-mapping tablespeios_access_map_genericexpects.
2.2 SIDs
Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors
A SID (Security Identifier) is the unique binary name of a principal. For the full account of what a SID is — its string and binary forms, the mixed endianness, the equality rule — see the operator-side page on SIDs. This section is the API for handling them.
A SID is small and bounded. The largest possible encoding is PEIOS_SID_MAX_BYTES (68) bytes, so a buffer of that size holds any valid SID and the SID builders below never need a two-call probe — you can always pass a PEIOS_SID_MAX_BYTES stack buffer and skip straight to the retrieve call.
2.2.0.1 Constructing SIDs #
Each of these encodes a SID into your buffer and returns its length (or -1 with errno). Because a SID fits in PEIOS_SID_MAX_BYTES, the probe is optional — but these are still ssize_t/two-call functions, so passing cap == 0 to probe works too.
| Function | Builds |
|---|---|
peios_sid_build(out, cap, id_authority, sub_auths, count) | An arbitrary SID from its parts: a 48-bit identifier authority (numeric, encoded big-endian) and count sub-authorities (encoded little-endian). count is 0..KACS_SID_MAX_SUB_AUTHORITIES. |
peios_sid_parse_string(out, cap, sddl) | A binary SID from its SDDL string form ("S-1-5-21-…"). |
peios_sid_integrity(out, cap, level_rid) | An integrity-label SID S-1-16-<rid> (see peios_integrity_level). |
peios_sid_logon(out, cap, session_id) | A logon SID S-1-5-5-<hi>-<lo> from a 64-bit session id. |
peios_sid_well_known(out, cap, which) | A well-known SID selected by enum peios_wks. |
ssize_t ;
ssize_t ;
ssize_t ;
ssize_t ;
ssize_t ;
peios_sid_build fails with EINVAL if count exceeds the maximum, and (like all of these) with ERANGE if a non-zero cap is too small.
2.2.0.2 Formatting and inspecting SIDs #
| Function | Returns |
|---|---|
peios_sid_format(sid, len, out, cap) | The SDDL string form ("S-1-…"), as a string length excluding the NUL — allocate len + 1. |
peios_sid_valid(sid, len) | true if sid is a structurally valid SID of exactly len bytes. |
peios_sid_length(sid) | The encoded length of sid, read from its sub-authority count. You must have already validated sid, or bounded it to PEIOS_SID_MAX_BYTES — this trusts the buffer. |
peios_sid_equal(a, alen, b, blen) | true for exact binary equality — the only equality KACS defines for SIDs. |
peios_sid_rid(sid, len) | The RID (last sub-authority), or 0 if the SID has none. |
ssize_t ;
bool ;
size_t ;
bool ;
uint32_t ;
The split between peios_sid_valid and peios_sid_length is deliberate: validation is the safe check that bounds an untrusted buffer; peios_sid_length is the fast reader you use after you trust the bytes (or when you have already capped the buffer at PEIOS_SID_MAX_BYTES). When in doubt, validate first.
2.2.0.3 Well-known SIDs #
peios_sid_well_known constructs any of the standard system principals without you memorising their numbers:
;
For the meaning of each principal, see Well-known principals.
2.2.0.4 Integrity levels #
Integrity-label SIDs have the form S-1-16-<rid>, where the RID names a level. peios_sid_integrity takes that RID; the standard levels are:
;
These are the labels that appear in a SACL as a SYSTEM_MANDATORY_LABEL ACE (see peios_acl_builder_label).
2.3 Access masks
Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors
An access mask is a 32-bit set of rights. Masks may contain four generic bits (KACS_ACCESS_GENERIC_READ/WRITE/EXECUTE/ALL) that stand in for object-specific rights until they are mapped to a concrete object class.
uint32_t ;
peios_access_map_generic folds the generic bits of mask into object-specific rights using the mapping m, and clears the generic bits from the result. Each object class publishes its canonical mapping as a data symbol you pass here — peios_file_generic_mapping (from <peios/file.h>) and peios_token_generic_mapping (from <peios/token.h>). Use it when you have a mask written in generic terms (say, from an SDDL string using GR/GW) and need the concrete rights for a specific object type.
2.4 Building ACLs
Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors
An ACL is an ordered list of ACEs. You assemble one with a peios_acl_builder — create it, add ACEs, take the serialised bytes, free it. Builders follow the sticky-error rules: the adders return void, the first error latches, and you check peios_acl_builder_error at the end.
typedef struct peios_acl_builder peios_acl_builder;
peios_acl_builder *; /* NULL on OOM */
void ;
void ;
peios_acl_builder_reset drops every accumulated ACE and clears the sticky error, so you can reuse one builder for several ACLs.
2.4.0.1 Adding ACEs #
The common single-SID families have convenience adders. flags is a mask of KACS_ACE_FLAG_* and is usually 0 — the flags carry inheritance semantics, which matter only for container/inheritable ACEs.
void ;
void ;
void ;
| Adder | Appends |
|---|---|
_allow | An ACCESS_ALLOWED ACE — grants mask to sid. |
_deny | An ACCESS_DENIED ACE — denies mask to sid. Order matters: put denies before allows. |
_audit | A SYSTEM_AUDIT ACE — logs access by sid matching mask. Belongs in a SACL, not a DACL. |
For an integrity label there is a dedicated adder:
void ;
It appends a SYSTEM_MANDATORY_LABEL ACE for integrity level S-1-16-<integrity_rid>. policy_mask is a mask of the KACS_SYSTEM_MANDATORY_LABEL_NO_{READ,WRITE,EXECUTE}_UP bits (from <pkm/sd.h>) that says which accesses a lower-integrity caller is denied. Like _audit, a label ACE belongs in a SACL.
For everything else — object ACEs, callback ACEs, resource-attribute ACEs — there is the general adder and a fully-specified ACE struct:
;
void ;
Fill in only the fields the type uses; leave the rest NULL/0:
- Object ACEs (
KACS_ACE_TYPE_*_OBJECT) readobject_typeandinherited_object_type— each a 16-byte GUID, orNULLwhen absent. - Callback and resource-attribute ACEs carry trailing
app_data(which isNULLonly whenapp_data_lenis0). For callback ACEs this is the conditional-expression bytecode you can produce withpeios_sddl_parse_condition.
The convenience adders are exactly peios_acl_builder_add with a pre-filled spec for the common cases; reach for _add when you need object, callback, or resource-attribute ACEs.
2.4.0.2 Taking the ACL bytes #
const void *;
ssize_t ;
int ;
peios_acl_builder_bytesborrows: it returns a pointer into the builder (valid until the next mutation,_reset, or_free), writing the length tolen_outif non-NULL. It returnsNULLif the sticky error is set.peios_acl_builder_finishcopies the serialised ACL out using the two-call protocol.peios_acl_builder_errorreturns the latched errno, or0if the builder is healthy.
The usual next step is to hand these bytes to peios_sd_builder_dacl or _sacl.
2.5 Building security descriptors
Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors
A security descriptor binds an owner, a group, a DACL, a SACL, and control flags into one self-relative buffer. Its builder mirrors the ACL builder's shape.
typedef struct peios_sd_builder peios_sd_builder;
peios_sd_builder *;
void ;
void ;
2.5.0.1 Setting components #
void ;
void ;
void ;
void ;
void ;
void ;
- Owner / group. Omit the call to leave the component absent. That is exactly what you want when building a partial SD to set only some components via
kacs_set_sd— the SD then carries only what you set. - Control bits.
peios_sd_builder_controlsets the bits insetand clears those inclear(KACS_SD_DACL_PROTECTED, and friends). You do not manageSELF_RELATIVEor the*_PRESENTbits — the builder maintains those for you as you add components. - DACL / SACL. Pass ACL bytes, typically straight from
peios_acl_builder_bytes. An ACL with zero ACEs is a present-but-empty DACL, which grants only the owner's implicit rights.
The DACL has one subtlety worth stating plainly. KACS has no NULL-DACL encoding — there is no "DACL present, pointer null" form; the kernel's parser rejects it. So "grant everyone everything" is expressed as an absent DACL (the DACL_PRESENT control bit clear). peios_sd_builder_dacl_null requests exactly that: it clears any DACL you set earlier and produces the same bytes as never setting a DACL at all. It exists so you can state the grant-all intent explicitly rather than by omission — but be clear that it means grant all, not deny all.
2.5.0.2 Taking the SD bytes #
Identical in shape to the ACL builder:
const void *;
ssize_t ;
int ;
_bytes borrows (valid until the next mutation/reset/free, NULL if errored), _finish copies out getxattr-style, _error returns the latched errno.
2.6 Parsing — views
Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors
To read a security descriptor, ACL, or ACE you use zero-copy views. A view is a caller-allocated, opaque, stack-friendly struct that borrows the buffer you parse — see the view rules. Every accessor that yields a SID, a nested ACL, or a blob returns a pointer into the original buffer, so that buffer must outlive the view and everything derived from it.
typedef struct peios_sd_view peios_sd_view;
typedef struct peios_acl_view peios_acl_view;
typedef struct peios_ace_view peios_ace_view;
typedef struct peios_sid_array_view peios_sid_array_view;
The _opaque arrays are sized for stack allocation with headroom — declare a view as a local and never read its fields.
2.6.0.1 Security-descriptor views #
int ;
uint16_t ;
int ;
int ;
int ;
int ;
peios_sd_parse validates a self-relative SD and populates out, returning 0 or -1 (EINVAL). peios_sd_view_control returns the raw control-bit word.
The four component accessors return 0 with their out-params set on success, or -1 if the component is absent. For the DACL and SACL, -1 also covers the NULL-DACL case — since an absent DACL and a NULL DACL are the same thing in KACS, a -1 from peios_sd_view_dacl uniformly means "no DACL constrains this object."
2.6.0.2 ACL and ACE views #
You can also parse a bare ACL directly — a token's default DACL, for instance, arrives as an ACL, not wrapped in an SD:
int ;
unsigned ;
int ;
peios_acl_view_count gives the number of ACEs; peios_acl_view_ace populates out for ACE i (0-based, in stored order), returning 0 or -1 (ERANGE for an out-of-range index). Iterate in the obvious way:
unsigned n = ;
for
Each ACE is read through its own accessors:
uint8_t ;
uint8_t ;
uint32_t ;
int ;
int ;
int ;
int ;
| Accessor | Yields |
|---|---|
_type / _flags / _mask | The ACE's KACS_ACE_TYPE_* type, KACS_ACE_FLAG_* flags, and 32-bit access mask. |
_sid | The trustee SID (a pointer into the buffer). 0 / -1. |
_object_type | The object GUID of an object ACE — 0 with *guid16 set to the 16 bytes, or -1 if not present / not an object ACE. |
_inherited_object_type | The inherited-object GUID, same convention. |
_app_data | Trailing application data of a callback or resource-attribute ACE — for a callback ACE, this is the conditional-expression bytecode you can render with peios_sddl_format_condition. |
2.6.0.3 SID-and-attributes arrays #
Several token classes — GROUPS, RESTRICTED_SIDS, DEVICE_GROUPS, CAPABILITIES — return a packed [count][sid_len][sid][attrs]… blob rather than an ACL. Parse those with the SID-array view:
int ;
unsigned ;
int ;
peios_sid_array_get yields the i-th entry's SID (a pointer into the blob), its length, and its 32-bit attribute word (the KACS_SE_GROUP_* flags — enabled, mandatory, deny-only, and so on).
2.7 SDDL text codec
Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors
The SDDL codec converts between the binary wire forms above and their human-readable SDDL text (MS-DTYP §2.5.1). This is a pure-userspace facility — the kernel speaks only binary — so it lives entirely in libpeios. All four entries use the two-call protocol (cap == 0 to probe) and fail with EINVAL on malformed input.
ssize_t ;
ssize_t ;
peios_sddl_parse_sdparses SDDL text (e.g."O:SYG:BAD:(A;;FA;;;BA)") into self-relative SD wire bytes.peios_sddl_format_sdrenders SD wire bytes back to a NUL-terminated SDDL string (length excludes theNUL, so allocatelen + 1).
These are the friendliest way to construct a descriptor when you have one written down — parse the string rather than assembling ACEs by hand — and the friendliest way to log or display one.
2.7.0.1 Conditional expressions #
Callback ACEs carry a conditional expression as compiled "artx" bytecode. The codec converts between that bytecode and its SDDL expression text:
ssize_t ;
ssize_t ;
peios_sddl_parse_conditioncompiles an expression such as@User.Title == "PM"into the bytecode you place in a callback ACE'sapp_data.peios_sddl_format_conditionrenders bytecode back to text (with no outer parentheses), length excluding theNUL.
So the round trip for a conditional ACE is: write the condition as text → peios_sddl_parse_condition → put the bytecode in peios_ace_spec.app_data with a callback ACE type → add it to an ACL builder.
2.8 SD inheritance
Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors
Inheritance — computing a child object's ACEs from its parent's inheritable ones — is also pure userspace (MS-DTYP §2.5.3.4). Both helpers take and produce self-relative SDs and use the two-call protocol.
ssize_t ;
ssize_t ;
peios_sd_reinherit recomputes a child SD's inherited ACEs from its parent. It strips the ACEs carrying ACE_FLAG_INHERITED from the child DACL, re-derives them from the parent DACL, and appends them after the child's explicit ACEs; the child's owner, group, SACL, and control bits pass through unchanged. is_container is non-zero if the child is itself a container (which determines how container-inherit and object-inherit flags propagate). This is what you call when a parent's ACL changed and you need to push the new inheritance down to a child.
peios_sd_strip_inherited drops the ACE_FLAG_INHERITED ACEs from the ACLs selected by info — a mask of *_SECURITY_INFORMATION bits, of which DACL_SECURITY_INFORMATION and SACL_SECURITY_INFORMATION are honoured and the rest ignored (selecting neither copies the input verbatim). Owner, group, and control bits pass through. Use it to reduce a descriptor to just its explicit ACEs — for example before storing a "protected" descriptor that should not carry inherited entries.
Both return the new SD's byte length, or -1 with EINVAL (malformed input) or ERANGE (a non-zero buffer too small).
3.1 token.h — Tokens and sessions
Peios / Developing for Peios / SDK Reference / token.h — Tokens
<peios/token.h> is the token surface of KACS. A token is the runtime object that carries an identity — a user SID, group SIDs, privileges, an integrity level, claims — and every access decision is made against one. This module lets you open the tokens that already exist (your own, another process's, a socket peer's), mint new ones, read their contents, transform them, and install or impersonate them.
A token handle is a file descriptor. Every open/create/duplicate call returns a raw int fd, O_CLOEXEC by default, that you close with close(). The access argument several calls take is the desired handle-right mask (KACS_TOKEN_*), access-checked against the token's own security descriptor and cached on the fd — a handle only lets you do what its rights allow.
The wire constants (KACS_TOKEN_*, KACS_IMLEVEL_*, KACS_SE_*_PRIVILEGE, KACS_TOKEN_CLASS_*, KACS_LOGON_TYPE_*) and the ioctl arg structs (kacs_priv_entry, kacs_group_entry) come from <pkm/token.h>. Query payloads that are SID arrays or ACLs are read with the views in <peios/security.h>.
The module divides into: opening & creating, the token-spec builder, query, adjust/transform, and logon sessions.
3.1.1 See also #
<peios/security.h>— the SID/ACL/SD vocabulary and the views used to parse group and privilege query payloads.<peios/access.h>— checking access with a token fd.- Tokens and Impersonation — the operator-side model.
3.2 Opening and creating tokens
Peios / Developing for Peios / SDK Reference / token.h — Tokens
Each of these returns a token fd (or -1 with errno).
int ;
int ;
int ;
int ;
int ;
| Function | Opens |
|---|---|
peios_token_open_self | The calling thread's token. flags may be KACS_TOKEN_OPEN_REAL to get the primary token even while the thread is impersonating; otherwise you get the effective (impersonation-aware) token. access is the desired handle rights. |
peios_token_open_process | The primary token of the process named by pidfd. Subject to a process-query access check and PIP dominance over the target. |
peios_token_open_thread | Thread tid's impersonation token if it is impersonating, else the process primary token. |
peios_token_open_peer | The peer-identity token captured at connect() on a connected Unix stream/seqpacket socket conn_fd — how a server learns who is on the other end of a socket. The handle carries fixed `QUERY |
peios_token_create_raw | Mints a token from a pre-built token-spec buffer. This is the escape hatch — prefer the builder below. Requires SeCreateTokenPrivilege. |
Errors, per call:
peios_token_open_self—EINVAL(unknownflags; empty or unknownaccessbits),EACCES(the token's own SD deniesaccess).peios_token_open_process—EACCES(any of the three checks failed — process-query right, PIP dominance, or the token SD; deliberately indistinguishable),EBADF(invalid pidfd),ESRCH(target exited),EINVAL(empty or unknownaccessbits).peios_token_open_thread— the_open_processset, plusESRCH(thread exited, or not inpidfd's process) andEINVAL(tid <= 0).peios_token_open_peer—EACCES(no captured peer token — an unconnected, datagram, or socketpair socket),ENOTSOCK(not a socket),EBADF(invalid fd).peios_token_create_raw—EPERM(privilege missing),EINVAL(spec failed kernel validation),EFAULT(bad spec pointer),ENOMEM(allocation failed).
peios_token_open_peer is the cornerstone of local authentication: accept a connection, open the peer token, and you have the caller's identity to query or impersonate — no password, no handshake, just the kernel's word for who connected.
3.3 The token-spec builder
Peios / Developing for Peios / SDK Reference / token.h — Tokens
Minting a token means assembling a 192-byte-header wire format with many optional sections. The builder is the ergonomic path — typed setters, no hand-packed offsets — and follows the standard sticky-error builder rules: the setters return void, the first error latches, you check peios_token_builder_error at the end, and you _free every builder.
typedef struct peios_token_builder peios_token_builder;
peios_token_builder *;
void ;
void ;
3.3.0.1 The index convention #
Three fields — the owner, the primary group, and the restrict/deny indices — refer to SIDs by index into the token's own SID list rather than by value. The convention is fixed:
Index 0 is the user SID. Indices 1..N are the 1st..Nth group you added with
peios_token_builder_add_group, in order.
So to make the second group the primary group, you set primary_group_index to 2. Do not add the logon SID yourself — the kernel injects it.
3.3.0.2 Core fields #
void ;
void ;
void ;
void ;
void ;
void ;
void ;
void ;
void ;
| Setter | Sets |
|---|---|
_user | The user SID (index 0). |
_add_group | Appends a group SID with its KACS_SE_GROUP_* attribute word (enabled, mandatory, deny-only, …). Call once per group, in the order you want them indexed. |
_privileges | The privilege bitmasks: present (which privileges the token holds) and enabled (which are on). Bits are KACS_SE_*_PRIVILEGE. |
_type | The token type (KACS_TOKEN_TYPE_* — primary or impersonation) and, for an impersonation token, the impersonation level imp_level (KACS_IMLEVEL_*). |
_integrity | The integrity level, as the RID of an S-1-16-<rid> label (see peios_integrity_level). |
_session | The logon session id the token references. |
_owner_index / _primary_group_index | Which SID (by index) is the default owner / primary group. |
_default_dacl | The default DACL applied to new objects the token creates (ACL bytes, e.g. from a peios_acl_builder). |
3.3.0.3 Advanced fields #
These cover the rest of the token-spec and can be left unset. They are marked [adv] in the header for a reason — most tokens need none of them.
void ;
void ;
void ;
void ;
void ;
void ;
void ;
void ;
void ;
| Setter | Sets |
|---|---|
_mandatory_policy | The mandatory-integrity policy bits governing how the integrity label is enforced. |
_projected_ids | The POSIX uid/gid this token projects into the Linux-compatibility layer. |
_expiration | An absolute expiry time after which the token is no longer valid. |
_source | The token's source: an 8-byte name and a source_id, recording who issued it (appears in audit). |
_audit_policy | Per-token audit policy bits. |
_add_restricted_sid | Appends a restricting SID (a write-restricted / restricted token intersects these against the normal SIDs). |
_add_device_group | Appends a device group SID (the device/machine side of a claim-aware token). |
_confinement | The confinement/AppContainer package SID that sandboxes the token. |
_supp_gids | Replaces the projected supplementary GIDs (pass NULL, 0 to clear). |
3.3.0.4 Token flags #
The four boolean token-spec flags are set together, so a designated initialiser reads clearly:
;
void ;
write_restricted— the token's restricting SIDs are checked only for write access.user_deny_only— the user SID is usable for deny ACEs but not to grant access.isolation_boundary— marks an isolation boundary for confinement.confinement_exempt— the token is exempt from confinement checks.
3.3.0.5 Claims #
A claim is a named, typed, multi-valued security attribute — the input to conditional (callback) ACEs. Claims come in user and device flavours; both share the same shape.
;
;
void ;
void ;
The value_type selects which member of each value carries the data:
value_type | Value member |
|---|---|
KACS_CLAIM_TYPE_INT64 / _UINT64 / _BOOLEAN | scalar (a boolean is 0 or 1). |
KACS_CLAIM_TYPE_STRING | bytes/len — a UTF-8 string (transcoded to UTF-16LE on the wire). |
KACS_CLAIM_TYPE_SID | bytes/len — a binary SID. |
KACS_CLAIM_TYPE_OCTET | bytes/len — an opaque blob. |
Each claim you add is round-tripped through the kernel's own claim parser before acceptance, so a malformed claim latches EINVAL on the builder immediately — you find out at build time, not at token-create time.
3.3.0.6 LCS registry credentials #
The final optional section grants the token registry-layer powers: which layer scopes it may resolve and which private layers it owns.
;
void ;
Setting it replaces any prior credentials; it is emitted as the last token-spec section. See <peios/registry.h> for what layers and scopes mean.
3.3.0.7 Finishing the builder #
ssize_t ;
int ;
int ;
peios_token_builder_bytesreturns the serialised length and, ifoutis non-NULL, writes a pointer into the builder (valid until the next reset/free) through it. Use this if you want the raw token-spec bytes.peios_token_builder_createdoes it in one step: serialise and mint, returning the new token fd. This is the usual call. It requiresSeCreateTokenPrivilege.peios_token_builder_errorreturns the latched errno, or0.
Errors: _bytes and _create first surface any latched builder error — EINVAL (malformed field, SID, claim, or index) or ENOMEM (allocation failed). A clean _create then adds the peios_token_create_raw set: EPERM (privilege missing), EINVAL (spec failed kernel validation), ENOMEM.
peios_token_builder *tb = ;
;
;
;
;
;
int tok = ; /* -1 on failure */
if
;
3.4 Query
Peios / Developing for Peios / SDK Reference / token.h — Tokens
You read a token's contents by information class. The generic reader handles any class getxattr-style; typed convenience wrappers cover the common ones.
ssize_t ;
ssize_t ; /* CLASS_USER */
peios_token_queryreads the classinfo_class(KACS_TOKEN_CLASS_*) intobufusing the two-call protocol. Classes that return SID arrays or ACLs are parsed afterward with the<peios/security.h>views — e.g. readCLASS_GROUPSinto a buffer, thenpeios_sid_array_parseit.peios_token_useris the same two-call read specialised to the user SID (CLASS_USER): probe withsid_buf == NULL, cap == 0, then retrieve.
For the common scalar classes there are typed helpers that write through a mandatory non-NULL out-pointer and return 0 / -1:
;
int ; /* CLASS_TYPE */
int ; /* CLASS_SESSION_ID */
int ; /* CLASS_INTEGRITY_LEVEL */
int ; /* CLASS_PRIVILEGES */
peios_token_privileges returns all four privilege words at once: which privileges are present, which are enabled, which are enabled_by_default, and which have been used (the audit trail of privilege use).
Errors (all query calls): EACCES (handle lacks QUERY), EINVAL (unknown class), ERANGE (non-probe buffer too small), EFAULT (bad buffer pointer). The typed helpers add EINVAL (NULL out-pointer, or an unexpected payload shape).
3.5 Adjust and transform
Peios / Developing for Peios / SDK Reference / token.h — Tokens
These change a token or derive a new one from it. Deriving calls return a new fd; in-place adjustments return 0 / -1.
3.5.0.1 Privileges and groups #
int ;
int ;
int ;
int ;
peios_token_adjust_privilegesenables/disables the privileges named inentries(each akacs_priv_entry); ifprev_enabledis non-NULLit receives the prior enabled mask, so you can restore it later.peios_token_reset_privilegesrestoresenabled := enabled_by_default. Errors:EACCES(handle lacksADJUST_PRIVILEGES),EINVAL(empty or oversized batch, duplicate entry, enabling an absent privilege, unknown attribute bits),EFAULT(bad entries pointer).peios_token_adjust_groupsis the group analogue.prev_state, if non-NULL, points at a caller array ofKACS_TOKEN_GROUP_MASK_WORDSuint64_twords that receives the prior enabled bitmask.peios_token_reset_groupsrestores the default group state. Errors:EACCES(handle lacksADJUST_GROUPS),EINVAL(mandatory, deny-only, or logon-SID group targeted; duplicate or out-of-range index; empty batch),EFAULT(bad entries pointer).
3.5.0.2 Duplicate and restrict #
int ;
;
int ;
peios_token_duplicatecopies the token, returning a new fd with handle rightsaccess, tokentype(KACS_TOKEN_TYPE_*), and impersonation levelimp_level(KACS_IMLEVEL_*). This is how you turn a primary token into an impersonation token, or narrow a handle's rights. Errors:EACCES(handle lacksDUPLICATE, or the new token's SD deniesaccess),EINVAL(unknowntype/imp_level, raising an impersonation token's level, empty or unknownaccessbits),ENOMEM(allocation failed).peios_token_restrictcreates a filtered token — the sandboxing primitive. It can delete privileges (privs_to_delete), demote groups to deny-only (deny_group_indices, by index), add restricting SIDs (restrict_sids/restrict_sid_lens), and setKACS_TOKEN_RESTRICT_WRITE_RESTRICTED. The result is a strictly less-powerful token you can hand to less-trusted code. Errors:EACCES(handle lacksDUPLICATE),EINVAL(duplicate or out-of-range deny index, malformed restricting SID, unknownflags,NULLspec or arrays),ENOMEM(allocation failed).
3.5.0.3 Impersonation and installation #
int ;
int ;
int ;
peios_token_installmakes this primary token the calling process's primary token. Errors:EACCES(handle lacksASSIGN_PRIMARY, orSeAssignPrimaryTokenPrivilegemissing),EINVAL(not a primary token),EAGAIN(thread set changed mid-install — retry),ENOMEM(allocation failed).peios_token_impersonatemakes this impersonation token the calling thread's effective identity — subsequent access checks on that thread run as the impersonated identity. Errors:EACCES(handle lacksIMPERSONATE),EINVAL(not an impersonation token),EPERM(restricted→unrestricted same-user — the one hard deny),ENOMEM(allocation failed).peios_token_revertundoes it: it clears the thread's impersonation token so checks run as the thread's real (primary) identity again. It takes no argument and is a no-op (reported as success) if the thread was not impersonating. This is the inverse ofpeios_token_impersonate— always pair them, ideally withrevertin the cleanup path. Errors: none in normal operation.
The archetypal server flow: peios_token_open_peer the caller → peios_token_impersonate it → do the work as them → peios_token_revert.
3.5.0.4 Linked tokens and defaults #
int ;
int ;
int ;
int ;
peios_token_linklinks an elevated + filtered primary-token pair insession_id— the UAC-style split-token model, where a filtered token is the everyday identity and its elevated linked token is available on demand.peios_token_get_linkedopens the linked token offd, returning a new fd. Errors (_link):EACCES(SeTcbPrivilegemissing, or either handle lacksDUPLICATE),EINVAL(self-link, role/session/user-SID mismatch, not primary tokens, unknownsession_id, or an fd that is not a token fd),EBADF(invalid fd). Errors (_get_linked):EACCES(handle lacksQUERY),ENOENT(not part of a linked pair, or the pair was destroyed),ENOMEM(allocation failed).peios_token_adjust_defaultreplaces the token's default DACL and/or owner/primary-group indices.dacl == NULLleaves the DACL unchanged (and ignoreslen);dacl != NULLwithlen == 0clears it; an index of0xFFFFleaves that index unchanged. Errors:EACCES(handle lacksADJUST_DEFAULT),EINVAL(out-of-range index; malformed or oversized DACL),EFAULT(bad DACL pointer).peios_token_set_session_idsets the token's session id (requiresSeTcbPrivilege). Errors:EACCES(handle lacksADJUST_SESSIONID, orSeTcbPrivilegemissing).
3.6 Logon sessions
Peios / Developing for Peios / SDK Reference / token.h — Tokens
A logon session is the lightweight kernel bookkeeping a token references — the "login" a token belongs to. Creating and destroying them requires SeTcbPrivilege.
;
int ;
int ;
peios_session_createcreates a logon session of typelogon_type(KACS_LOGON_TYPE_*— interactive, network, service, …) foruser_sid, attributing it toauth_package.id_outis mandatory and receives the new session id, which you then pass topeios_token_builder_session. Errors:EPERM(SeTcbPrivilegemissing),EINVAL(NULLspec,id_out, or field; malformed SID; oversized spec),EFAULT(bad pointer),ENOMEM(allocation failed).peios_session_destroy_emptydestroys a session that has no live tokens — it fails rather than orphaning tokens. Clean up sessions only after every token referencing them is closed. Errors:EPERM(SeTcbPrivilegemissing),ENOENT(no such session),EBUSY(live tokens, linked-pair state, or in-flight references).
3.7 The generic mapping
Peios / Developing for Peios / SDK Reference / token.h — Tokens
extern const struct kacs_generic_mapping peios_token_generic_mapping;
The canonical generic→specific rights mapping for the token object class. Pass it to peios_access_map_generic or as the mapping in a peios_access_request when the object under check is a token.
4.1 access.h — Access checks
Peios / Developing for Peios / SDK Reference / access.h — Access Checks
<peios/access.h> answers the central question of the whole access-control model: may this subject perform this access on this object? You hand it a token, a security descriptor, and a desired access mask, and it runs the full KACS AccessCheck pipeline and tells you whether access is granted and exactly which rights were granted.
Two things are worth saying up front:
- These calls are advisory. They evaluate, they do not enforce.
peios_access_checktells you what the answer would be; enforcement of a real operation always runs inside the kernel against the subject's own process security block. Use these when your code is the resource manager — you hold an object, you have its security descriptor, and you need to make the grant/deny decision yourself. - A denial is a normal result, not an error. Per the library conventions, a denied check returns
-1witherrno == EACCES, and the granted mask is still written out. Only a genuine failure (a bad token fd, a malformed SD) is an error in the usual sense.
4.1.1 See also #
<peios/security.h>— building the security descriptors and reading the generic-mapping tables this check consumes.<peios/token.h>— obtaining thetoken_fdto check, andpeios_token_generic_mapping.- Access decisions — the operator-side account of how KACS reaches a grant/deny decision.
4.2 The request
Peios / Developing for Peios / SDK Reference / access.h — Access Checks
Every check is described by a single struct peios_access_request. Only the first block is needed for an ordinary check; everything below the divider is advanced and may be left zero/NULL. For every pointer/length pair, NULL is valid only when the matching length or count is zero.
;
4.2.0.1 The core fields #
| Field | Meaning |
|---|---|
token_fd | The subject token to evaluate. -1 means the caller's own effective token — the common case when you are checking access for yourself. Otherwise pass a token fd from <peios/token.h>. |
sd / sd_len | The object's security descriptor, as self-relative wire bytes — typically from a peios_sd_builder or read off the object. |
desired | The access mask you want checked. May contain generic bits; the mapping resolves them. |
mapping | The object class's generic mapping (a struct kacs_generic_mapping), so generic rights in desired and in the SD's ACEs fold to the right object-specific bits. Use the class's published table — e.g. peios_file_generic_mapping or peios_token_generic_mapping. |
4.2.0.2 The advanced fields #
Leave these zero/NULL unless you need them:
| Field | Meaning |
|---|---|
self_sid / self_sid_len | The SID to substitute for PRINCIPAL_SELF (S-1-5-10) in ACEs — the "self" the object belongs to. |
privilege_intent | Backup/restore intent bits, letting SeBackupPrivilege / SeRestorePrivilege widen the granted mask as they would for a real backup or restore. |
object_tree / object_tree_count | An object-type tree for a per-property check (object ACEs with type GUIDs). Mandatory for peios_access_check_list. |
local_claims / local_claims_len | An @Local claim array to evaluate conditional ACEs against, beyond the claims already on the token. |
pip_type / pip_trust | Process-integrity-protection trust label to evaluate against; pip_type == 0 uses the subject's own PSB. |
audit_context / audit_context_len | An opaque object identifier stamped into any audit events the check generates. |
4.3 The check
Peios / Developing for Peios / SDK Reference / access.h — Access Checks
int ;
Runs the full AccessCheck pipeline. Returns:
0if every right indesiredis granted;-1witherrno == EACCESif any desired right is denied;-1with another errno on a real error (e.g.EBADFfor a badtoken_fd,EINVALfor a malformed SD).
granted, if non-NULL, always receives the granted access mask — even on denial. This is the useful part: you can request a broad desired and read back exactly which subset was granted, rather than probing one right at a time. audit, if non-NULL, receives the audit outputs.
struct peios_access_request req = ;
uint32_t granted = 0;
int rc = ;
if else if else
libpeios owns the versioned struct kacs_access_check_args under the hood — it sets caller_size and zeroes the reserved fields so the request stays forward-compatible across kernel versions. You only ever fill in the peios_access_request above.
4.4 Audit outputs
Peios / Developing for Peios / SDK Reference / access.h — Access Checks
;
When you pass a non-NULL audit, the check reports:
continuous_audit— the OR of the alarm masks of anySYSTEM_AUDITACEs that matched, i.e. what a continuous-audit consumer would log for this access.staging_mismatch—1if evaluating the staged central access policy would have produced a different result than the active one. This is the signal you watch when rolling out a central access policy change: a non-zero value means the pending policy would decide this access differently.
4.5 The object-type-list variant
Peios / Developing for Peios / SDK Reference / access.h — Access Checks
int ;
peios_access_check_list is the AccessCheckByTypeResultList form — a per-node check over an object-type tree, for objects whose properties or property sets carry their own object ACEs (a directory-service-style object, say). It evaluates the whole tree in one call and reports a separate result for each node.
req->object_tree/object_tree_countare mandatory here — they describe the tree ofkacs_object_type_entrynodes to evaluate.resultsreceives onekacs_node_resultper node, in preorder, andcountmust equalreq->object_tree_count.- Returns
0/-1(EINVALifcountdoesn't match, and the usual errors otherwise).
Each kacs_node_result carries that node's granted mask and status, so you can discover, for example, that a caller may read most of an object but not one protected property — in a single check rather than one per property.
5.1 file.h — File security
Peios / Developing for Peios / SDK Reference / file.h — File Security
<peios/file.h> is the file surface of KACS. Where ordinary POSIX open() gives you a file descriptor governed by mode bits, peios_file_open performs a native KACS open — an NtCreateFile-shaped call carrying a desired access mask, a create disposition, create options, and an optional creator security descriptor — and hands back an ordinary Linux file fd whose granted access mask is fixed for the fd's lifetime. Because the grant is baked into the fd, it can be delegated safely by dup, SCM_RIGHTS, or across exec: whoever holds the fd holds exactly the access it was opened with, no more.
Alongside the open, this module reads and writes a file's security descriptor (by path or by fd) and governs how a superblock without native SD storage is treated.
The wire constants (KACS_DISPOSITION_*, KACS_CREATE_OPT_*, KACS_FILE_*, KACS_SECINFO_*, KACS_MOUNT_POLICY_*, KACS_STATUS_*) come from <pkm/file.h> and <pkm/sd.h>. The security descriptors these calls exchange are built and parsed with <peios/security.h>.
5.1.1 See also #
<peios/security.h>— building the creator SDs and parsing the SDs these calls return.<peios/access.h>— evaluating a file SD withpeios_file_generic_mapping.- File access and Mount policies — the operator-side model of native file security.
5.2 Opening a file
Peios / Developing for Peios / SDK Reference / file.h — File Security
;
int ;
peios_file_open opens path relative to dirfd (the usual *at convention — an absolute path ignores dirfd, and AT_FDCWD means the current directory). It returns a file fd, or -1 with errno.
The parameters:
| Field | Meaning |
|---|---|
desired_access | The access mask you are requesting — KACS_FILE_* object rights, standard rights, or (in strict mode) generic bits the file class maps. The granted subset is what the returned fd is fixed at. |
disposition | What to do about existence: KACS_DISPOSITION_* — open-existing, create-new, open-or-create, supersede, overwrite, and so on. This is the create/open decision open() splits across O_CREAT/O_EXCL/O_TRUNC. |
options | KACS_CREATE_OPT_* create options — directory-vs-file, no-follow, write-through, delete-on-close, and the rest of the NtCreateFile option set. |
flags | AT_SYMLINK_NOFOLLOW, plus the privilege-intent flags KACS_BACKUP_INTENT / KACS_RESTORE_INTENT that let SeBackupPrivilege / SeRestorePrivilege widen the access the open is granted. |
sd / sd_len | The creator security descriptor — the SD to stamp on a newly created file. Pass NULL when opening an existing file (or to let the parent's inheritance decide the new file's SD). |
status_out, if non-NULL, receives a KACS_STATUS_* code telling you what happened — whether the file was opened, created, superseded, overwritten. This is how you distinguish "created a new file" from "opened the existing one" after an open-or-create disposition, without a separate stat race.
Errors: EACCES (a requested right denied — strict mode), EEXIST (create-new and the file exists), ENOENT (open-existing and it doesn't), ENOTDIR (directory option, non-directory target), ELOOP (no-follow and the target is a symlink), EINVAL (MAXIMUM_ALLOWED without a concrete data/execute bit, malformed creator SD, NULL path/p, sd == NULL with sd_len != 0), EBADF (bad dirfd).
struct peios_open_params p = ;
uint32_t status = 0;
int fd = ;
if
/* status == KACS_STATUS_CREATED or KACS_STATUS_OPENED */
libpeios marshals these params into a struct kacs_open_how for you — setting its size and zeroing the reserved fields — so the call stays forward-compatible across kernel versions.
5.3 Reading and writing a file's security descriptor
Peios / Developing for Peios / SDK Reference / file.h — File Security
A file's SD can be accessed by path or by fd. In both cases secinfo is a mask of KACS_SECINFO_* bits selecting which components (owner, group, DACL, SACL, …) the operation touches — you read or write just the parts you name and leave the rest alone.
The rights required scale with the components you touch (see Managing file security):
Component (KACS_SECINFO_*) | Reading needs | Writing needs |
|---|---|---|
OWNER / GROUP | READ_CONTROL | WRITE_OWNER (plus owner-SID validation) |
DACL | READ_CONTROL | WRITE_DAC |
SACL | ACCESS_SYSTEM_SECURITY | ACCESS_SYSTEM_SECURITY |
LABEL | READ_CONTROL | WRITE_OWNER (the label cannot rise above the caller's integrity without SeRelabelPrivilege) |
ACCESS_SYSTEM_SECURITY is itself gated by SeSecurityPrivilege; READ_CONTROL and WRITE_DAC are implicitly granted to the owner. SACL and LABEL cannot be combined in one call (EINVAL). The check is all-or-nothing: if any requested component fails its check, the whole call fails.
5.3.0.1 By path #
ssize_t ;
int ;
peios_file_get_sdreads thesecinfo-selected components ofpath's SD intobuf, getxattr-style (two-call protocol — probe withcap == 0, and a too-small non-zero buffer failsERANGEwithout truncating).at_flagsacceptsAT_SYMLINK_NOFOLLOW. Errors:EACCES(component right missing),EINVAL(SACL+LABELtogether;NULLpath, orNULLbuffer with non-zerocap),ERANGE(non-probe buffer too small),ENOENT(path doesn't exist),ELOOP(no-follow and symlink).peios_file_set_sdwrites thesecinfocomponents ofsdontopath, preserving the components you did not select. So to change only the DACL, build an SD with a DACL, passsecinfo = KACS_SECINFO_DACL, and the owner/group/SACL are untouched. Errors:EACCES(component right missing),EPERM(owner-SID validation failed withoutSeRestorePrivilege; label raised withoutSeRelabelPrivilege; MANDATORY attribute removed withoutSeTcbPrivilege),EINVAL(malformed SD,SACL+LABELtogether,NULLor zero-lengthsd),ENOENT,ELOOP.
5.3.0.2 By fd #
ssize_t ;
int ;
The same operations against the object fd already refers to. The access check they perform depends on the fd type: a normal file fd is checked against its cached granted mask (the one baked in at open), while an O_PATH, pidfd, or token fd triggers a live check. That distinction — cached for the fixed-grant file fd, live for the others — is documented in the Peios Kernel TRM §3.9, FACS; the practical upshot is that a file fd already opened with the right access can get/set its SD without a second path resolution.
The required rights and errors match the by-path calls, minus the path-resolution failures (ENOENT/ELOOP), plus EBADF (bad fd).
5.4 Mount policy
Peios / Developing for Peios / SDK Reference / file.h — File Security
Not every filesystem can store native security descriptors. The mount policy governs how KACS treats a superblock that has no native SD storage — whether files there get a synthesised SD, a template SD, or are denied. These calls target the superblock the object fd lives on and require SeTcbPrivilege.
;
int ;
int ;
peios_mount_get_policyreads the policy forfd's superblock intoout. The template SD is returned into yourtmpl_bufgetxattr-style: on successout->template_sdpoints intotmpl_bufwhen that buffer was large enough, or isNULLif the superblock has no template. ANULLtemplate buffer (ortmpl_cap == 0) is valid only when you don't need the template bytes. A too-small template buffer is not an error — the call still succeeds, reports the true length inout->template_sd_len, and leavesout->template_sdNULLso you can size a retry. Errors:EPERM(SeTcbPrivilegemissing),EBADF(bad fd),EINVAL(NULLout, orNULLtmpl_bufwith non-zerotmpl_cap),EFAULT(bad buffer pointer),ENOMEM(allocation failed).peios_mount_set_policyinstallspas the superblock's policy.policyis aKACS_MOUNT_POLICY_*value;template_sd/template_sd_lensupply the template SD when the policy calls for one.flagsandgenerationmust be zero on set — the kernel manages the generation counter itself and rejects a non-zero input. Errors:EPERM(SeTcbPrivilegemissing),EINVAL(unknown or unmanagedpolicy, non-zeroflags/generation, malformed or oversized template,NULLtemplate with non-zero length),EOPNOTSUPP(superblock not KACS-managed),EBADF(bad fd),EFAULT(bad pointer).
5.5 The generic mapping
Peios / Developing for Peios / SDK Reference / file.h — File Security
extern const struct kacs_generic_mapping peios_file_generic_mapping;
The canonical generic→specific rights mapping for the file object class. Pass it to peios_access_map_generic, or as the mapping in a peios_access_request when checking access against a file's SD — for example to pre-flight whether a caller could open a file before you actually open it.
6.1 process.h — Process security
Peios / Developing for Peios / SDK Reference / process.h — Process Security
<peios/process.h> is the process-security surface of KACS. Today it is a small module with a single job: turning on process mitigations — the hardening controls that live on a process's security block (PSB). More process-security surface will land here as it appears; for now, this is the mitigation control.
The mitigation bits are the KACS_MIT_* flags from <pkm/psb.h> (KACS_MIT_WXP through KACS_MIT_SML, with KACS_MIT_ALL as the mask of all valid bits). KACS_MIT_CFI is a legacy alias that expands to KACS_MIT_CFIF | KACS_MIT_CFIB. The full catalogue and semantics are in the Peios Kernel TRM §3.3, the Process Security Block.
6.1.1 Setting mitigations #
int ;
Turns on the mitigation bits named in mitigations (a mask of KACS_MIT_*). Returns 0 on success, or -1 with errno.
Three properties define how this call behaves, and each matters:
- It is one-way. Mitigation bits can only be set, never cleared. Once a protection is on, it stays on for the life of the process. This is deliberate — a mitigation you could turn off is a mitigation an attacker could turn off — so treat each call as a permanent, additive commitment.
- It targets a process by pidfd.
pidfd == -1targets the calling process, which is the common case: a program hardens itself early in startup. Targeting another process requiresPROCESS_SET_INFORMATIONon it plus PIP dominance over it — you cannot harden (or interfere with) a process you don't already dominate. - It is activation-backed and fails closed. If a requested protection cannot actually be activated, the call fails without mutating anything — you never end up believing a mitigation is on when it isn't. Either every requested bit is activated and the call succeeds, or nothing changes and it returns
-1.
/* Harden the current process: enforce W^X and shadow-stack, refuse to
proceed if either can't be activated. */
if
Because the call is all-or-nothing, request the bits you require together and check the result once: a success means the whole set is active, a failure means none of this call's bits were applied (bits set by earlier successful calls remain on).
6.1.2 See also #
<peios/token.h>— PIP dominance is determined by the subject's token; process targeting other than self depends on it.- Process mitigations — the operator-side account of each mitigation and what it defends against.
7.1 registry.h — The registry (LCS)
Peios / Developing for Peios / SDK Reference / registry.h — The Registry
<peios/registry.h> is the client surface of LCS — the Layered Configuration Subsystem, Peios's kernel-mediated registry. LCS is modelled on the Windows registry: a hierarchy of keys (each with an immutable GUID identity and secured by its own KACS security descriptor) holding typed values. Its distinguishing feature is layers: every write is tagged with a precedence-ordered layer, and the effective view of a value resolves to the highest-precedence entry. That is what lets a base configuration, a site overlay, and a machine-local override coexist on one key and resolve deterministically.
This header is the registry client: open keys, read and write values, enumerate, watch, secure, back up, and run transactions. It does not cover the registry source (the storage backend) side — REG_SRC_REGISTER and the RSI framed protocol — which is a separate library, librsi. A client speaks only the syscalls and ioctls here.
Handles are fds. Three calls create file descriptors — peios_reg_open_key, peios_reg_create_key, and peios_reg_begin_transaction; everything else is an operation on a key fd or transaction fd, gated on the access right granted when the key was opened. The wire constants — value types (REG_SZ … REG_QWORD), key access rights (KEY_*), open/create flags, transaction states (REG_TXN_*), watch filters (REG_NOTIFY_*), and security-info bits — come from <pkm/lcs.h>.
7.1.1 See also #
<peios/security.h>— building and parsing the SDs that secure keys.- Library conventions — the base error and buffer rules the descriptor reads specialise.
- The registry — the operator-side model of layers, hives, and precedence.
7.2 The buffer convention here
Peios / Developing for Peios / SDK Reference / registry.h — The Registry
Most of libpeios returns variable-length data with an ssize_t and the two-call protocol. The registry's reads use the same idea but express it through descriptor structs rather than a return value, because a single read often fills more than one buffer (a value's data and its layer name, say). The pattern:
- Each read takes a descriptor struct with
*_capfields (in) and*_lenfields (out), plus buffer pointers. - On success it returns
0and writes the actual length into each*_len. - If a buffer is too small it returns
-1witherrno == ERANGEand writes the required length into the matching*_len— so a zero-capacity buffer probes the size. - A
NULLbuffer is valid only with zero capacity;NULLwith a nonzero capacity isEINVAL. - For a read with two buffers,
ERANGEis returned if either is too small, and both required lengths are reported, so one probe sizes everything.
Everything else follows the usual Linux convention: 0 / -1 + errno.
7.3 Opening and creating keys
Peios / Developing for Peios / SDK Reference / registry.h — The Registry
int ;
int ;
Both resolve path (NUL-terminated) against parent_fd — a key fd for a relative path, or < 0 for an absolute path — and return a key fd whose granted access mask is fixed for its lifetime (like a file fd, so it can be delegated). desired_access is the requested KEY_* rights, checked against the key's SD.
peios_reg_open_keyopens an existing key.flagsmay beREG_OPEN_LINKto open a symlink key itself rather than following it. Errors:ENOENT,EACCES,EINVAL,ELOOP,ENAMETOOLONG,ETIMEDOUT,EIO,ENOMEM.peios_reg_create_keyopens an existing key or creates a new one.flagsmay combineREG_OPTION_VOLATILE(a key that does not survive reboot) andREG_OPTION_CREATE_LINK(create a symlink key).layernames the target layer to create in (NUL-terminated), orNULLfor the base layer.txn_fdenlists the create in a transaction, or-1to auto-commit.disposition_out, if non-NULL, receivesREG_CREATED_NEWorREG_OPENED_EXISTING. Errors addENOSPCandEPERM(privileged symlink creation) to the set above.
7.4 Values
Peios / Developing for Peios / SDK Reference / registry.h — The Registry
A value is named (length-counted; an empty name is the key's default value), typed (REG_*), and written into a layer. A base-layer target is layer == NULL with layer_len == 0; a non-NULL pointer with a zero length is rejected EINVAL.
7.4.0.1 Reading a value #
;
int ;
peios_reg_query_value reads the effective value name on key_fd — the winner of the layer precedence resolution. name_len == 0 reads the default value; txn_fd reads within a transaction, or -1 for none. It fills v->data with the value bytes and v->layer with the name of the layer that won, and reports the resolved type and sequence. Pass a NULL layer buffer if you don't care which layer won. Errors: ENOENT (no effective value, or a tombstone masks it), ERANGE, EACCES, EINVAL.
7.4.0.2 Writing, deleting, tombstoning #
int ;
int ;
int ;
peios_reg_set_valuewrites valuenameoftypeinto a specificlayer(NULL/0= base).typemay beREG_TOMBSTONEto place a per-value tombstone that masks lower layers.expected_seqis a compare-and-swap guard:0disables it; otherwise the write applies only if the value's current sequence matches, elseEAGAIN. This is how you do lost-update-safe read-modify-write — read thesequencefrompeios_reg_query_value, then set withexpected_seqset to it. Errors:EINVAL,EAGAIN,ENOSPC,ENAMETOOLONG,EPERM,EACCES.peios_reg_delete_valueremoves a layer's entry forname(NULL/0= base). It is idempotent, and removing a layer's entry lets any lower-layer value re-emerge — deletion is per-layer, not global.peios_reg_blanket_tombstonesets (set != 0) or clears (set == 0) a blanket tombstone on a layer, masking all lower-precedence values of this key on that layer at once — the wholesale version of a per-value tombstone.setmust be0or1(elseEINVAL).
7.4.0.3 Enumerating values #
int ;
;
int ;
Two ways to read every effective value of a key:
peios_reg_query_values_batchreads them all into onebufin a single call — the efficient path. Each record is packed little-endian, back to back:[name_len: u32][name][type: u32][data_len: u32][data], forcountrecords.len_outreceives the bytes written (or the required size onERANGE);count_outreceives the record count. Both may beNULL.peios_reg_enum_valuereads one value at a time byindex, dense over the key's tombstone-resolved values — walk from0untilENOENT. Use it when you want to process values incrementally rather than buffer them all.
7.5 Subkeys, metadata, and watches
Peios / Developing for Peios / SDK Reference / registry.h — The Registry
7.5.0.1 Enumerating subkeys #
;
int ;
peios_reg_enum_subkey reads the child key at index, dense over visible children — walk from 0 until ENOENT. There is no per-child access check during enumeration (you see the names and counts; opening a child still checks its SD).
7.5.0.2 Key metadata #
;
int ;
peios_reg_query_key_info reads the key's leaf name and its metadata (needs READ_CONTROL). Note the ordering wrinkle: the kernel reports the metadata only once the name fits, so a too-small (or zero-capacity) name buffer returns ERANGE with the required name_len and no metadata — size the name buffer from that, then call again to get everything. The max_* fields are sizing hints for enumerations; hive_generation is a per-hive change epoch you can watch to detect that anything under the hive changed.
7.5.0.3 Deleting and hiding keys #
int ;
int ;
Both need DELETE access, take a layer (NULL/0 = base) and an optional txn_fd, and cannot target a hive root (EINVAL).
peios_reg_delete_keyremoves this key's path entry in a layer; lower-layer entries re-emerge. It fails withENOTEMPTYif the key has visible children.peios_reg_hide_keycreates aHIDDENpath entry that masks the key in a layer; removing that layer makes the key reappear. This is the key-level analogue of a tombstone — hide rather than destroy.
7.5.0.4 Watching for changes #
int ;
int ;
peios_reg_notifyarms change watches onkey_fd(needsKEY_NOTIFY).filteris a mask ofREG_NOTIFY_VALUE/REG_NOTIFY_SUBKEY/REG_NOTIFY_SD(orREG_NOTIFY_ALL);subtree(0/1) extends the watch to descendants.filter == 0disarms. Once armed, the key fd itself becomes pollable —EPOLLINsignals pending events, andread()on the fd returns the change records. So a watch integrates directly into anepollloop with no side channel. Errors:ENOENT(orphaned key),EINVAL,EACCES.peios_reg_flushforces the source to persist this key's hive's pending writes (needsKEY_SET_VALUE) and returns once persistence is confirmed — the durability barrier.
The change records. A read() on an armed key fd returns as many complete records as fit in your buffer — records are never split across reads. If the buffer is too small for even the next record the read fails EINVAL (so size it generously — a few KiB), and a non-blocking fd with nothing pending fails EAGAIN. Each record is a little-endian, possibly unaligned byte stream (Peios Kernel TRM §5.6, Watches, with the header offsets in §5.A):
| Offset | Size | Field | Meaning |
|---|---|---|---|
| 0 | 4 | total_len | Record size in bytes — advance by this to the next record (future versions may append fields). |
| 4 | 2 | event_type | REG_WATCH_VALUE_SET / _VALUE_DELETED / _SUBKEY_CREATED / _SUBKEY_DELETED / _SD_CHANGED / _KEY_DELETED / _OVERFLOW. |
| 6 | 2 | name_len | Byte length of name; 0 for the no-name events (SD_CHANGED, KEY_DELETED, OVERFLOW). |
| 8 | name_len | name | The changed value or subkey name (UTF-8, not NUL-terminated). |
A subtree watch appends two further fields after name: path_depth (u16) and that many length-prefixed path components (u16 length + UTF-8 bytes), locating the changed key relative to the watched key — depth 0 means the watched key itself.
Delivery is best-effort with an overflow fallback: if records accumulate faster than you read them, the oldest are dropped and a REG_WATCH_OVERFLOW record is queued — on seeing one, re-read the watched key (and subtree) to recover current state rather than trusting the stream. Records describe effective (layer-resolved) changes, and uncommitted transactions produce none — events fire at commit.
7.6 Key security descriptors
Peios / Developing for Peios / SDK Reference / registry.h — The Registry
int ;
int ;
Keys are KACS-secured, so their SDs are read and written with the same <peios/security.h> vocabulary as files and tokens; security_info selects components (owner/group/DACL/SACL).
peios_reg_get_securityreads the selected components intosd(KACS binary form), writing the length to*sd_len_out(may beNULL); a too-small buffer returnsERANGEwith the required size there, and a zerocapprobes. Owner/group/DACL needREAD_CONTROL; the SACL needsACCESS_SYSTEM_SECURITY.peios_reg_set_securityapplies the selected components ofsd, merging with the rest (the kernel parses and validates). The DACL needsWRITE_DAC, the ownerWRITE_OWNER, the SACLACCESS_SYSTEM_SECURITY. Heretxn_fdgives atomicity, not layer qualification (SDs are not layered), or-1to apply immediately. SD changes affect only future opens — handles already open keep their fixed grant.
7.7 Backup and restore
Peios / Developing for Peios / SDK Reference / registry.h — The Registry
int ;
int ;
peios_reg_backupexports the key and its entire subtree tooutput_fd(needsSeBackupPrivilege). It takes a read-only snapshot and performs no per-key access check — the privilege is the gate. Errors:EPERM/EACCES,EBADF(output not writable),ENOENT,ENOTSUP,EBUSY.peios_reg_restorereplaces the key and its entire subtree frominput_fd(needsSeRestorePrivilege), applied in one transaction. Errors:EPERM/EACCES,EBADF(input not readable),EINVAL(malformed stream),EEXIST(GUID collision),EOVERFLOW.
7.8 Transactions
Peios / Developing for Peios / SDK Reference / registry.h — The Registry
A transaction batches key creates and mutating value/key operations into an atomic unit.
int ;
int ;
int ;
peios_reg_begin_transactionstarts one and returns a transaction fd (initially unbound; it binds to a source on first use), or-1/ENOMEM. Pass this fd as thetxn_fdargument to the create and mutating calls to enlist them. Closing the fd without committing aborts the transaction — so a transaction is abort-by-default, which makes error paths safe.peios_reg_commitatomically applies everything enlisted. On success the fd is terminal — close it. Errors tell you what to do:EINVAL(already committed / never bound),EBUSY(write-lock contention — the transaction stays active, retry the commit),EIO(source failure — stays active),ETIMEDOUT.peios_reg_txn_statusreads a transaction's state:state_outreceives theREG_TXN_*state, andterminal_errno_outreceives the errno that ended it (0while active or after a clean commit). Both may beNULL.
The lifecycle: begin → enlist operations by passing txn_fd → commit (retry on EBUSY/EIO) → close, or just close to abort.
8.1 event.h — Events (KMES)
Peios / Developing for Peios / SDK Reference / event.h — Events
<peios/event.h> is the client surface of KMES — Peios's sole event path. The kernel stamps every event with trusted metadata (timestamp, per-CPU sequence, CPU id, identity GUIDs) and writes it into a per-CPU lock-free ring buffer. There is no other way to emit or observe events: audit records, subsystem events, and your own application events all flow through the same rings. Producers emit; consumers attach to the rings and drain them.
Each event payload is a single MessagePack value — build and parse it with <peios/msgpack.h>.
Two privileges gate the module: emitting requires SeAuditPrivilege, and consuming (attaching to a ring) requires SeSecurityPrivilege.
8.1.1 See also #
<peios/msgpack.h>— building and parsing the payloads events carry.- Auditing — the operator-side view of the event and audit stream.
8.2 Emitting events
Peios / Developing for Peios / SDK Reference / event.h — Events
int ;
Emits a single event. event_type is a length-counted UTF-8 event kind such as "my.app.login" — not NUL-terminated, and its length must be non-zero. payload is payload_len bytes of MessagePack (one well-formed value). The kernel validates the payload (one well-formed MessagePack value within the configured size and nesting limits) and stamps origin_class = userspace. Returns 0, or -1 with errno:
| errno | Cause |
|---|---|
EPERM | No SeAuditPrivilege. |
EINVAL | Zero-length type, or a malformed payload. |
ENOSPC | Payload exceeds the size caps. |
EAGAIN | Rate-limited. |
EFAULT | Bad pointer. |
Since the kernel's payload check matches peios_mp_validate, you can validate in userspace first and turn a would-be EINVAL into a check you control.
/* Build a payload, then emit. */
peios_mp_writer *w = ;
;
; ;
const void *buf; ssize_t n = ;
if
;
;
8.2.0.1 Batch emit #
;
int ;
peios_event_emit_batch emits several events in one call, amortising the per-call overhead — a single timestamp capture, identity capture, and consumer wake cover the whole batch. count is in [1, KMES_BATCH_MAX_ENTRIES]. It returns 0 if all count were emitted, or -1 with the errno of the first entry that failed, with *emitted_out (if non-NULL) set to how many entries preceded the failure — so you know exactly where to resume. Rate-limiting is all-or-nothing here: an EAGAIN emits none of the batch.
8.3 Consuming events
Peios / Developing for Peios / SDK Reference / event.h — Events
A consumed event is described by struct peios_event. The kernel-stamped header is copied to you by value; the two variable parts point into the ring mapping.
;
The trusted metadata is the point of KMES: the timestamp, the identity GUIDs (the effective and true tokens, and the process), and the origin_class are stamped by the kernel and cannot be forged by the emitter. sequence is per-CPU, per-boot monotonic — a gap in it means events were lost (overwritten before you drained them).
Lifetime:
event_typeandpayloadpoint into the ring mapping and are valid only until the next read advance, and only while the slot has not been overwritten. Copy out whatever you need before continuing to the next event.
8.3.0.1 Attaching to a ring #
int ;
The low-level primitive: attach to CPU cpu_id's ring buffer, returning a fd and writing the data-region capacity to *capacity_out. Discover the CPU count by counting up from 0 until peios_event_attach returns -1 with errno == EINVAL. Requires SeSecurityPrivilege (EPERM otherwise). You then mmap the fd via peios_event_ring_map. Most callers should use the high-level reader instead, which does the attach and mmap for you.
8.3.0.2 The high-level reader #
typedef struct peios_event_reader peios_event_reader;
peios_event_reader *;
void ;
int ;
int ;
uint64_t ;
The reader owns the attach + mmap and hides the whole lock-free drain — memory barriers, lapping recovery, sequence-gap (lost-event) accounting, buffer resize/generation handling, and the futex wait. You just loop next/wait.
peios_event_reader_openattaches tocpu_idand maps its ring, ready to drain (NULLwitherrnoon failure).peios_event_reader_closetears it down.peios_event_reader_nextfetches the next event intoout(non-NULL). Returns1(event filled),0(none available right now — considerwait), or-1witherrno. Theoutpointers are valid only until the next call.peios_event_reader_waitblocks until events are available ortimeout_mselapses (negative = forever). Returns1(callnext),0(timeout/interrupted), or-1.peios_event_reader_lostreturns the cumulative count of lost events (from sequence gaps) — poll it to monitor whether you're draining fast enough.
The canonical consume loop, per CPU:
peios_event_reader *r = ;
for
;
To consume the whole machine, run one reader per CPU (discover the count as above), each typically on its own thread.
8.3.0.3 The low-level ring #
For callers that want to drive the drain themselves — integrating the rings into a custom event loop, say — the ring API exposes the mapping directly. The accessors apply the correct memory barriers; you own the read position and the empty/lapping/generation checks.
; /* opaque */
int ;
void ;
uint64_t ;
uint64_t ; /* acquire */
uint64_t ; /* acquire */
uint64_t ;
void ;
ssize_t ;
int ;
peios_event_ring_mapmaps and validates a ring fd frompeios_event_attach;ringmust be zeroed or previously unmapped (remapping an active ring failsEBUSY).peios_event_ring_unmapreleases it.- Positions are free-running byte counters.
write_posis where the producer will write next (acquire-loaded);tail_posis the oldest still-live byte (advances as the ring laps); an event lives at(read_pos & (capacity - 1)). You drain by walkingread_posfromtail_postowardwrite_pos.generationchanges when the buffer is resized — re-readcapacitywhen it does. peios_event_ring_event_atparses the event atread_posintooutand returns its byte size (advanceread_posby that), or-1if the slot is corrupt. You must have confirmedread_posis in[tail_pos, write_pos)first. Passout == NULLto validate a slot and get its size without borrowing theevent_type/payloadpointers.- Before sleeping, arm the advisory wake flag with
peios_event_ring_set_need_wake(ring, 1), thenpeios_event_ring_waitfutex-waits until events pastread_posmay be available ortimeout_mselapses (negative = forever):1(drain now),0(timeout/interrupted),-1.
The low-level loop mirrors the high-level one but with the position bookkeeping in your hands:
uint64_t rp = ;
for
Reach for this only when the high-level reader's loop doesn't fit your event model; for almost everything, peios_event_reader_* is the right tool.
9.1 msgpack.h — MessagePack codec
Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding
<peios/msgpack.h> is a small, self-contained MessagePack codec. It exists because KMES event payloads are MessagePack: the kernel only structurally validates a payload on emit — it does not build or interpret it — so userspace owns the encode and decode. This codec is that path, and its validator's acceptance is deliberately matched to the kernel's emit-time check, so a payload this codec produces and validates is guaranteed to be accepted by peios_event_emit.
You can use it as a general MessagePack codec, but its reason for being is events.
It has three parts: a heap-backed writer, a stack-allocatable reader, and a validator.
9.1.1 See also #
<peios/event.h>— the KMES events these payloads travel in.- Library conventions — the sticky-error builder model the writer follows.
9.2 Conventions
Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding
A few rules hold across the codec:
- Integers are written in their smallest MessagePack form automatically — you write an
int64/uint64and the encoder picks the compact encoding. strvalues must be valid UTF-8. Usebinfor arbitrary bytes. The reader enforces this onstrreads too.- A valid payload is exactly one top-level value, and an empty buffer is not valid. (A map or array at the top counts as that one value.)
- The writer is sticky-error, exactly like the
<peios/security.h>builders: the write calls cannot fail individually; the first error latches and surfaces atpeios_mp_writer_bytes/peios_mp_writer_error.
9.3 Writer
Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding
typedef struct peios_mp_writer peios_mp_writer;
peios_mp_writer *;
void ;
void ;
Create a writer, append values, take the bytes, free it (or reset to reuse). All the append calls return void — errors latch.
9.3.0.1 Scalars #
void ;
void ;
void ;
void ;
void ;
void ; /* UTF-8 */
void ;
Use peios_mp_write_int for signed and peios_mp_write_uint for unsigned values; both are stored in the smallest form. peios_mp_write_str takes UTF-8 with an explicit length (no NUL needed); peios_mp_write_bin takes arbitrary bytes.
9.3.0.2 Containers #
void ;
void ;
Write the header, then exactly the promised number of values. A map of count needs 2 * count values — count key/value pairs — written key, value, key, value…. An under- or over-filled container is not caught at the write call; it surfaces at peios_mp_writer_bytes, when the whole structure is validated.
/* {"user": "alice", "ok": true} */
;
; ;
; ;
9.3.0.3 Extensions and raw bytes #
void ;
void ;
peios_mp_write_extwrites a MessagePack extension value with a signed type id.peios_mp_write_rawappends pre-encoded MessagePack bytes verbatim — the escape hatch for splicing in a value you already have encoded. The result is still structurally validated as a whole atpeios_mp_writer_bytes, so you can't smuggle malformed bytes through it.
9.3.0.4 Taking the bytes #
ssize_t ;
int ;
peios_mp_writer_bytes confirms the buffer is exactly one well-formed top-level value, then borrows it: it writes a pointer to the encoded bytes through out (valid until the next mutating call on w) and returns the length. Pass out == NULL to validate and get the length without borrowing. It returns -1 with errno — EINVAL on a latched error or a malformed/under-filled structure, ENOMEM on a prior allocation failure. peios_mp_writer_error returns the latched errno directly, or 0.
Because this call validates, a successful peios_mp_writer_bytes is your guarantee the bytes are emit-ready.
9.4 Reader
Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding
The reader is a cursor over a borrowed buffer — stack-allocatable, no heap, no free. It decodes one value at a time, advancing the cursor.
; /* opaque — do not inspect */
void ;
size_t ;
Declare a struct peios_mp_reader locally and peios_mp_reader_init it over your buffer before use. buf may be NULL only when len is zero. Borrowed str/bin/ext pointers the reader hands back point into the original buffer and are valid for as long as it lives. peios_mp_reader_remaining reports the unconsumed byte count.
9.4.0.1 Peeking #
;
int ;
peios_mp_peek returns the peios_mp_type of the next value without consuming it, or -1 at end-of-input or on an invalid lead byte. Note that integers of every width and sign report as PEIOS_MP_INT — read them with peios_mp_read_int or peios_mp_read_uint as you prefer. Peek is how you drive a dispatch over a value whose type you don't know ahead of time.
9.4.0.2 Reading scalars #
int ;
int ;
int ;
int ;
int ;
Each consumes one value on success (returns 0) and leaves the cursor untouched on a type mismatch or truncation (-1 with errno == EINVAL) — so a failed read is safe to follow with a different-typed read or a peek. The out pointer is optional: pass NULL to consume/type-check a value without receiving its payload.
9.4.0.3 Reading strings, bytes, containers, extensions #
ssize_t ;
ssize_t ;
ssize_t ;
ssize_t ;
ssize_t ;
int ;
peios_mp_read_str/peios_mp_read_binborrow the bytes (a pointer into the reader's buffer viaout) and return the length, or-1. Strings are not NUL-terminated — use the length — andpeios_mp_read_strrejects invalid UTF-8.peios_mp_read_arrayreturns the element count;peios_mp_read_mapreturns the key/value pair count (so read2 * countvalues). After the header you read that many values yourself.peios_mp_read_extborrows an extension value's bytes, reporting its signed type id throughtype_out(bothtype_outandoutare independently optional), and returns the data length.peios_mp_skipconsumes exactly one complete value, descending into nested containers — the way to ignore a value (or a whole subtree) you don't care about.0/-1.
struct peios_mp_reader r;
;
ssize_t pairs = ; /* top-level map */
for
9.5 Validator
Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding
int ;
peios_mp_validate confirms buf/len is exactly one well-formed MessagePack value: UTF-8 strings, nesting bounded by max_depth, no trailing bytes, non-empty. Returns 0 if valid, -1 with errno == EINVAL otherwise.
Crucially, its acceptance matches the kernel's emit-time check, so a 0 return means the event emit calls will accept the payload — at this depth bound. Pass KMES_CONFIG_MAX_NESTING_DEPTH_DEFAULT (32) for the default emit limit; the top-level value is depth 1. Validate before emitting when a payload comes from an untrusted or dynamic source, so you turn a would-be EINVAL from the kernel into a check you control.
10.1 rsi/source.h — Becoming a source
Peios / Developing for Peios / SDK Reference / rsi/source.h — Becoming a Source
<rsi/source.h> is where a registry source begins. A source is a storage backend for the LCS registry — the provider counterpart to libpeios's registry client. Where a client opens keys and reads values, a source is what actually holds those keys and values and answers the kernel's requests for them.
This header has one job: registration. You declare which hives your process backs, register with the kernel, and get back a source fd. From that point on you serve the RSI (Registry Source Interface) protocol on that fd — reading requests and writing responses. Registration requires SeTcbPrivilege. The RSI wire constants (RSI_HIVE_PRIVATE, RSI_*) come from <pkm/lcs.h>.
This is part of librsi, a separate library from libpeios — link -lrsi and include <rsi.h> (or the individual <rsi/*.h>). It follows the same library conventions: raw fds, int returning 0/-1+errno, and the errno passed straight through from the kernel.
10.1.1 See also #
- Registry sources overview — what a source is and how the RSI protocol flows.
- The registry — the operator-side model of hives, layers, and sources.
10.2 Describing a hive
Peios / Developing for Peios / SDK Reference / rsi/source.h — Becoming a Source
A hive is a subtree of the registry with its own root key. A source declares one struct rsi_hive per hive it backs:
;
| Field | Meaning |
|---|---|
name / name_len | The hive's name, length-counted (not NUL-terminated). |
flags | RSI_HIVE_PRIVATE for a private (scoped) hive, or 0 for a global one. |
root_guid | The GUID of the hive's root key — the anchor every path in the hive resolves from. |
scope_guid | For a private hive, the scope GUID that bounds who can resolve it; zero for a global hive. |
A global hive is visible system-wide; a private hive is scoped by scope_guid and resolvable only by tokens holding that scope (see the token LCS credentials). Set RSI_HIVE_PRIVATE and a non-zero scope_guid together for a private hive; leave both clear for a global one.
10.3 Registering
Peios / Developing for Peios / SDK Reference / rsi/source.h — Becoming a Source
int ;
Opens /dev/pkm_registry and registers all count hives in one call, returning the source fd — the descriptor you then read(2) requests and write(2) responses on — or -1 with errno.
| Argument | Meaning |
|---|---|
hives / count | The hives this source serves. count must be >= 1; the kernel enforces its configured MaxHivesPerSource limit. |
max_sequence | The highest sequence number this source has already persisted. The kernel resumes its global sequence counter past this value, so a source that has durable state from a previous run must report it here to avoid reusing sequence numbers. A fresh source with no persisted state passes 0. |
Errors include EPERM (no SeTcbPrivilege), EINVAL, ENOSPC (over the hive limit), ENOMEM, EFAULT, and any error from the underlying /dev/pkm_registry open(2).
struct rsi_hive hive = ;
int src = ;
if
/* `src` is now the source fd — serve the RSI protocol on it. */
The max_sequence parameter is the one piece of state a durable source must get right: on restart, scan your persisted data for the highest sequence you ever wrote and pass it, so the kernel never hands out a sequence number you've already used.
10.4 What comes next
Peios / Developing for Peios / SDK Reference / rsi/source.h — Becoming a Source
Registration is the whole of this header. Once you hold the source fd, the serve loop lives in the other two:
<rsi/request.h>— read and decode the requests the kernel sends.<rsi/response.h>— build and send the replies.
The serving requests guide ties them together into a working serve loop.
11.1 rsi/request.h — Decoding requests
Peios / Developing for Peios / SDK Reference / rsi/request.h — Decoding Requests
<rsi/request.h> is the receiving half of a registry source's serve loop. The kernel sends your source RSI requests — "look up this child", "store this value", "begin this transaction" — as framed messages on the source fd. This header reads one frame, splits its header from its payload, and decodes the payload into a flat, typed struct you can act on.
The shape of the loop is always: read a frame → parse the header → dispatch on the op-code → decode the payload with the matching parser. The decoders are thin wrappers over the kernel's own RSI parsers, so your wire handling is guaranteed compatible with what the kernel sent.
Borrowing: every decoded name/data field is a
(ptr, len)pair that borrows into your frame buffer. The pointers are valid only until you reuse that buffer for the nextrsi_read_request. Copy out anything you need to keep across iterations. This is the same borrow discipline as libpeios's views.
Op-code and field constants (RSI_LOOKUP, RSI_WRITE_KEY_FIELD_*, RSI_TXN_*) come from <pkm/lcs.h>.
11.1.1 See also #
<rsi/response.h>— building the reply each op expects.- Serving requests — the read/parse/dispatch/respond loop in full.
11.2 Reading and parsing a frame
Peios / Developing for Peios / SDK Reference / rsi/request.h — Decoding Requests
;
ssize_t ;
int ;
rsi_read_requestreads one framed request from the source fd intobuf— a thinread(2)wrapper that blocks until a request is queued, then returns the frame length (pass it torsi_parse_request). It returns0at EOF (the source is closing — leave the loop) or-1witherrno, notablyEMSGSIZEifcapis smaller than the pending frame (sizebufgenerously, or grow and retry).rsi_parse_requestsplits a frame into its header and payload view, fillingoutwith therequest_id(which you must echo in the response), thetxn_id(0when the request is not inside a transaction), theop_codeto dispatch on, and a borrowedpayloadpointer. Returns0, or-1witherrno(EINVALon NULL args,EBADMSGon a malformed frame).
11.3 The decoders
Peios / Developing for Peios / SDK Reference / rsi/request.h — Decoding Requests
Each decoder takes the parsed req and fills a flat struct: GUIDs by value, names and data as borrowed (ptr, len) pairs. All return 0, or -1 with errno — EINVAL if the arguments are NULL or the decoder doesn't match req->op_code (so calling the wrong decoder for an op is a clean error), and EBADMSG on a malformed payload. You dispatch on req.op_code and call the matching one.
11.3.0.1 Path and entry operations #
These operate on the name→GUID bindings that make up the key hierarchy. A child is named under a parent GUID, and entries live in layers.
/* LOOKUP — is child_name visible under parent_guid? */
;
int ;
/* CREATE_ENTRY — bind child_name → child_guid in layer_name. */
;
int ;
/* HIDE_ENTRY — tombstone child_name in layer_name. */
;
int ;
/* DELETE_ENTRY — remove child_name's entry in layer_name. */
;
int ;
/* ENUM_CHILDREN — list the children of parent_guid. */
;
int ;
| Op | You must | Reply with |
|---|---|---|
LOOKUP | Resolve child_name under parent_guid across your layers. | rsi_respond_lookup |
CREATE_ENTRY | Bind child_name → child_guid in layer_name at sequence. | status |
HIDE_ENTRY | Place a tombstone for child_name in layer_name. | status |
DELETE_ENTRY | Remove child_name's entry in layer_name. | status |
ENUM_CHILDREN | List every child of parent_guid. | rsi_respond_enum_children |
11.3.0.2 Key operations #
These operate on key metadata records — the non-layered facts about a key (its name, parent, security descriptor, flags).
/* CREATE_KEY — create the metadata record guid under parent_guid. */
;
int ;
/* READ_KEY / DROP_KEY — a request carrying just a key GUID. */
;
int ;
int ;
/* WRITE_KEY — update the mutable fields of guid named by field_mask. */
;
int ;
| Op | You must | Reply with |
|---|---|---|
CREATE_KEY | Store the metadata record for guid (its name, parent, sd, and the volatile_key/symlink flags). | status |
READ_KEY | Return the metadata of guid. | rsi_respond_read_key |
DROP_KEY | Delete the metadata record for guid. | status |
WRITE_KEY | Update only the fields selected in field_mask — the SD when RSI_WRITE_KEY_FIELD_SD is set, the last_write_time when its bit is set — leaving the rest untouched. | status |
WRITE_KEY's field_mask is the important detail: sd is NULL unless the SD bit is set, and last_write_time is meaningful only when the time bit is set, so consult the mask before reading either.
11.3.0.3 Value operations #
These operate on the typed values stored on a key, each written into a layer.
/* QUERY_VALUES — read value_name (or all values when query_all) of guid. */
;
int ;
/* SET_VALUE — store value_name in layer_name with the given type/data. */
;
int ;
/* DELETE_VALUE_ENTRY — remove value_name's entry in layer_name. */
;
int ;
/* SET_BLANKET_TOMBSTONE — set or clear a blanket tombstone on layer_name. */
;
int ;
| Op | You must | Reply with |
|---|---|---|
QUERY_VALUES | Return value_name — or every value when query_all is 1 (then value_name is ignored) — plus any blanket tombstones. | rsi_respond_query_values |
SET_VALUE | Store value_name of value_type in layer_name. Honour expected_sequence as a compare-and-swap guard (0 disables it) — reject with a non-OK status if the current sequence differs. | status |
DELETE_VALUE_ENTRY | Remove value_name's entry in layer_name. | status |
SET_BLANKET_TOMBSTONE | Set (set == 1) or clear a blanket tombstone on layer_name, masking all lower values at once. | status |
11.3.0.4 Transaction operations #
The kernel drives transaction boundaries; your source honours them so a group of writes commits or aborts atomically.
/* BEGIN_TRANSACTION — open transaction_id in mode. */
;
int ;
/* COMMIT_TRANSACTION / ABORT_TRANSACTION — a request carrying just a transaction id. */
;
int ;
int ;
| Op | You must | Reply with |
|---|---|---|
BEGIN_TRANSACTION | Open transaction_id in mode (RSI_TXN_READ_WRITE or RSI_TXN_READ_ONLY); buffer subsequent writes tagged with this id. | status |
COMMIT_TRANSACTION | Atomically apply everything buffered under transaction_id. | status |
ABORT_TRANSACTION | Discard everything buffered under transaction_id. | status |
Requests that belong to a transaction carry its id in req.txn_id; a txn_id of 0 means the request is outside any transaction.
11.3.0.5 Layer operations #
/* DELETE_LAYER / FLUSH — a request carrying just a length-prefixed name. */
;
int ;
int ;
| Op | You must | Reply with |
|---|---|---|
DELETE_LAYER | Remove the entire named layer, reporting the GUIDs of any keys it orphaned. | rsi_respond_delete_layer |
FLUSH | Durably persist pending writes for the named hive, replying only once persistence is confirmed. | status |
12.1 rsi/response.h — Building responses
Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses
<rsi/response.h> is the sending half of a source's serve loop. After you handle a request, you reply on the source fd with a framed response. This header builds those frames for you: you pass the result as flat arrays, and librsi validates and heap-encodes the wire frame — you never hand-pack a byte.
Every response echoes the request's id and its op-code (OR'd with the response bit) and carries an RSI_* status. Most operations are status-only; five carry a payload on success. Any operation can report a non-OK status with the status-only helper.
For the wire, a response is a 14-byte header (echoed request id, op-code | RSI_RESPONSE_BIT) plus a 4-byte RSI_* status, followed by an op-specific payload for payload-bearing successes; multi-byte integers are little-endian and names/data are length-prefixed. You don't assemble any of that — the helpers do. Status and target-type constants (RSI_OK, RSI_PATH_TARGET_GUID, …) come from <pkm/lcs.h>.
12.1.1 See also #
<rsi/request.h>— decoding the request each of these replies to.- Building responses — choosing and filling the right responder.
12.2 Status codes
Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses
Every response carries exactly one of these statuses. The kernel translates a non-OK status into the errno the registry client sees, so send the code that matches what actually happened:
| Code | When to send it |
|---|---|
RSI_OK | The operation succeeded. Status-only ops report it via rsi_respond_status; the five payload-bearing ops must use their own helper. |
RSI_NOT_FOUND | The requested key, entry, value, or layer does not exist in your store (client sees ENOENT). |
RSI_ALREADY_EXISTS | A create collided with something that already exists (client sees EEXIST). |
RSI_STORAGE_ERROR | Your backing store failed — I/O error, corruption, anything the client can't fix (client sees EIO). |
RSI_NOT_EMPTY | The operation needs the key to have no children, and it has some (client sees ENOTEMPTY). |
RSI_TOO_LARGE | The data exceeds what the source is willing or able to store (client sees ENOSPC). |
RSI_TXN_BUSY | A transaction can't proceed right now — e.g. write-lock contention; the operation may be retried (client sees EBUSY). |
RSI_INVALID | The request is well-formed RSI but violates the source's rules or refers to something malformed (client sees EINVAL). |
RSI_CAS_FAILED | A sequence-guarded write's expected_sequence did not match the current entry — the compare-and-swap lost (client sees EAGAIN and retries). |
RSI_TXN_NOT_SUPPORTED | Reply to BEGIN_TRANSACTION from a source that does not implement transactions (client sees ENOTSUP). |
12.3 The response contract
Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses
All rsi_respond_* helpers return 0, or -1 with errno. A set of rules applies to every helper, and violating one is an EINVAL caller-contract error:
(ptr, len)pairs: a pointer may beNULLonly when its length/count is zero.- Boolean fields (
volatile_key,symlink, target types) must be exactly0or1. - Hidden path targets (
RSI_PATH_TARGET_HIDDEN) must carry an all-zerotarget_guid. LOOKUP/ENUM_CHILDRENmetadata must exactly cover the GUID path targets referenced — no missing metadata, no duplicates, no unreferenced entries.DELETE_LAYERorphan GUIDs must be nonzero and unique.
Beyond EINVAL, any helper can also fail with ENOMEM (during validation or frame allocation), EOVERFLOW (validation arithmetic or the assembled frame too large), EIO (a short write), or the raw write(2) errno. Per-helper EINVAL additions are noted below.
12.4 Sending a pre-built frame
Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses
ssize_t ;
Writes one already-built response frame to the source fd — a thin write(2) wrapper returning the bytes written, or -1 with errno. Most callers never need this; the rsi_respond_* helpers build and send. It exists for callers assembling frames by other means.
12.5 Status-only responses
Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses
int ;
The workhorse. Use it for:
- status-only ops on success — pass
status = RSI_OK; and - any op reporting a non-OK status — a
LOOKUPthat found nothing, aSET_VALUEthat failed a compare-and-swap, a permission error: reply with the appropriateRSI_*status here, whatever the op.
It fails with EINVAL on a bad req, an unknown status, or RSI_OK given for a payload-bearing op (those must use their own helper on success), plus EIO / the write error.
The rule of thumb: on failure, always rsi_respond_status; on success, rsi_respond_status unless the op is one of the five below.
12.6 Payload-bearing responses
Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses
Five operations return data on success. Each takes the result as flat arrays and encodes the frame for you.
12.6.0.1 LOOKUP #
;
;
int ;
Answers a LOOKUP with the resolved path entries for the child — one per layer that has a view of it, each either a GUID target or a HIDDEN (tombstone) target — plus the metadata for every key the entries reference. A RSI_PATH_TARGET_HIDDEN entry must carry an all-zero target_guid; the metadata must exactly cover the GUID targets. EINVAL if req is not a LOOKUP, a nonzero count has a NULL array, or an entry has invalid target/boolean fields, missing/duplicate metadata, or unreferenced metadata.
12.6.0.2 ENUM_CHILDREN #
;
int ;
Answers an ENUM_CHILDREN with each child — its name and the path entries that resolve it — plus the metadata for every referenced key. The same target/boolean/metadata-coverage rules as LOOKUP apply. EINVAL on the same conditions, scoped to ENUM_CHILDREN.
12.6.0.3 READ_KEY #
int ;
Answers a READ_KEY with the key's non-layered metadata: its name, parent_guid, security descriptor (sd), the volatile_key/symlink flags, and last_write_time. EINVAL if req is not a READ_KEY, parent_guid is NULL, or a boolean field is invalid.
12.6.0.4 QUERY_VALUES #
;
;
int ;
Answers a QUERY_VALUES with the value entries — each value's name, the layer it lives in, its type, data, and sequence — plus the blankets (the blanket tombstones on this key, each a layer and sequence). The kernel resolves precedence across the layers you report. EINVAL if req is not a QUERY_VALUES or a nonzero count has a NULL array.
12.6.0.5 DELETE_LAYER #
int ;
Answers a DELETE_LAYER with the GUIDs of the keys the deleted layer orphaned — a flat orphaned_count * 16-byte array. The GUIDs must be nonzero and unique. EINVAL if req is not a DELETE_LAYER or a nonzero count has a NULL array, a nil GUID, or a duplicate.
12.7 The five at a glance
Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses
| Success response | Op | Payload |
|---|---|---|
rsi_respond_lookup | LOOKUP | path entries + referenced key metadata |
rsi_respond_enum_children | ENUM_CHILDREN | children (name + path entries) + metadata |
rsi_respond_read_key | READ_KEY | one key's non-layered metadata |
rsi_respond_query_values | QUERY_VALUES | value entries + blanket tombstones |
rsi_respond_delete_layer | DELETE_LAYER | orphaned key GUIDs |
Every other op — and every failure of these — is rsi_respond_status.
What DWE is
Peios / Developing for Peios / DWE
Developer Workflow Embeddings are dedicated development paths built into core Peios software: the tools carry first-class support for developing against them, rather than being poked at from the outside by whatever a developer can improvise.
Its first and most general component is dwed, a service that gives you a persistent, maximally-privileged way to talk to a machine that is already running. Everything below is about that.
The problem it solves #
Debugging a live system means asking it one question, reading the answer, and asking a better one. The loop is only as good as how quickly you can go round it.
Without something like dwed, driving a Peios machine from outside means a serial console: boot the machine, feed it a script, read what comes back, and start again. Three things about that hurt more than they look:
- Nothing survives. Each run is a fresh boot, so every question has to be planned in advance and packed into one script. A question that occurs to you halfway through the output cannot be asked without starting over.
- The output is one stream. A console interleaves what you typed, what the shell echoed, and what the program wrote to
stdoutandstderr— with no reliable way to pull them apart afterwards. Exit codes are not carried at all unless the script prints them itself. - You cannot be
SYSTEM. A console session is a logon session belonging to a person. The most privileged thing a machine has to offer is not reachable through it at all.
dwed exists because the machine is already persistent. What was missing was anything willing to talk to it in between questions.
What it gives you #
A socket into a running machine that answers structured requests as SYSTEM:
stdoutandstderrcome back separately, as raw bytes, with a real exit status.- Commands run directly, not through a shell, so there is no quoting layer between what you meant and what ran.
- Work can be detached — started now, collected several connections later. The job outlives the connection that started it, which is what makes an investigation spanning hours possible.
- Files move in and out whole and binary-safe, rather than through
base64improvised into a shell pipeline.
The privilege #
dwed is started by peinit as an ordinary service with Identity = SYSTEM. It constructs no tokens of its own; the privilege arrives entirely from that one line in its service definition. Asked on a running machine, its token reports:
user Local System (S-1-5-18)
type Primary
logon_type 5 (Service)
privileges 36, all enabled — including SeCreateToken, SeTcb,
SeAssignPrimaryToken, SeDebug, SeBackup, SeRestore,
SeImpersonate, SeLoadDriver, SeSecurity, SeAudit
This is the same privilege peinit itself holds, deliberately. A full SYSTEM account is not reachable from a console at all, and reaching one is the single capability that makes dwed worth having over a serial login.
It is also why the rest of this page is about containment.
The security posture #
dwed does not authenticate its peer, and cannot.
The transport is vsock, which crosses a hypervisor boundary between two separate kernels. A guest kernel can be told a peer's context id, but it cannot attest anything about who is behind it — those are claims, not attestation, and no amount of work inside the guest changes that. Peios' identity model rules AF_VSOCK out as a carrier of process identity for exactly this reason.
So authentication is the job of whatever surrounds the machine — the host it runs on, the network it sits behind — and never of dwed.
Three things follow, and all three are load-bearing:
It is never published as a package. The peios-dwe package does not exist in the public repository and never will. It reaches a machine only inside a dedicated peios-dwe ISO, so it cannot arrive anywhere by way of an ordinary install.
Distribution is the only real control. The usual advice — "do not install it in production" — does not apply cleanly, because the machines DWE is wanted on are production in every sense except intent. There is no honest way to enforce the distinction from inside the software. Keeping it out of the repository is what stops it turning up somewhere by accident.
Installing it is not enough to start it. A package may ship a service definition but may not start it: the definition sits inert in the vendor seed library until an image names it in [registry] autoapply. For dwed, that opt-in is the moment a machine becomes remotely ownable — so it is a decision the image makes explicitly, not a consequence of a package being present.
What DWE is not #
It is not a test harness. Provium covers deterministic, repeatable testing of a whole system, from initramfs through to network interaction, and does it far better than anything built on dwed could. Tests belong there.
DWE is for the case Provium cannot serve: a machine that is already running, already misbehaving, and needs to be asked questions nobody thought to write down in advance. When a Provium test fails for a reason that is not obvious, DWE is how you go and look.
It is not a general remote-administration tool. There is no session model, no pty, no terminal multiplexing, and no plan for any of them until something concrete needs one. dwed is a way to ask a running machine questions, and the machine — not the connection — is the thing that persists.
Next #
- Driving a machine — booting with a vsock device, and the
dwecommand. - The DWE protocol — the wire format, for building against it directly.
Driving a machine
Peios / Developing for Peios / DWE
This page assumes an image built with the peios-dwe package and its service seed applied. If you are not sure, What DWE is explains why both are needed and why neither is the default.
Give the machine a transport #
dwed binds a vsock listener at boot, but a guest cannot conjure the transport itself — the hypervisor has to give it a vsock device. Under QEMU that is one flag:
The context id is how the host addresses this guest. Any value of 3 or above works; QEMU refuses to start if another running guest already holds the one you picked, so concurrent machines need distinct ids.
In the Peios tree, make boot-dwe is make boot with that device attached:
Leave it running. Unlike a scripted boot, the point is that the machine stays up.
Point the client at it #
The dwe client takes its target from --target or from DWE_TARGET:
# a VM by context id
# or over the network
The port defaults to 4820 and can be left off. Confirm you have the machine you think you have:
$ dwe info
dwed 0.1.0
protocol 1
boot id 79880a4b-e3d0-4992-bea2-eb56f3839711
uptime 193s
The boot id is worth reading. It changes on every boot, so it is how you tell "the machine rebooted under me" from "my connection dropped" — two situations that otherwise look identical and mean very different things.
Run something #
$ dwe exec -- ls -l /system
$ dwe exec --cwd /tmp -- ./probe
Everything after -- is the argument vector, executed directly. There is no shell, so nothing re-interprets your quoting, globs your arguments or splits them on spaces. When you want a shell, ask for one:
Two behaviours matter more than they look:
The exit status is yours. dwe exec exits with the guest command's status, so ordinary shell chaining works:
&&
A command killed by a signal exits 128+N, matching shell convention, so it is distinguishable from one that merely failed.
The streams stay apart. The guest's stdout and stderr are written to your stdout and stderr, still separated, as raw bytes:
$ dwe exec -- ls /nonexistent 2>errors.txt
$ cat errors.txt
ls: cannot access '/nonexistent': No such file or directory
Move files #
| Transfers are binary-safe and whole-file. Encoding is handled inside the protocol, so you never have to improvise base64 through a shell pipeline to get a binary out intact.
Work that outlives the connection #
This is the part that makes a long investigation possible. --detach returns a job handle immediately, and the job keeps running when the connection closes:
$ dwe exec --detach -- make -C /src world
1
Come back whenever — a minute later, an hour later, over as many separate connections as you like:
$ dwe jobs
1 running make -C /src world
$ dwe output 1
[... everything so far ...]
$ dwe output 1 --follow # or watch it live
$ dwe signal 1 15 # or stop it
Reading only what is new #
Polling a job repeatedly with plain dwe output re-reads everything from the start. To pick up where you left off, ask for the offsets and pass them back:
$ dwe output 1 --offsets
[... output ...]
--since-stdout 4096 --since-stderr 128
$ dwe output 1 --since-stdout 4096 --since-stderr 128
[... only what arrived since ...]
The cursor is yours to keep rather than something dwed tracks. It has no sessions and cannot tell two callers apart, so a server-side cursor would have two people polling the same job eating each other's output.
When it does not answer #
cannot reach vsock:3:4820 — the machine is not running, has no vsock device, or is using a different context id. Check the QEMU command line for vhost-vsock-pci.
The connection opens but nothing answers — dwed is not running in the guest. Its service definition was shipped but never applied: an image has to name dwed-service.reg in [registry] autoapply for anything to start. On the console, look for peinit: service dwed started.
protocol mismatch — dwe and dwed are from different builds. The wire version is checked rather than guessed at, so this is reported instead of being allowed to misparse. Rebuild both.
Nothing at all, and the machine is wedged — dwed goes down with the machine it is debugging. Below that line the serial console is still the tool.
The DWE protocol
Peios / Developing for Peios / DWE
dwed speaks newline-delimited JSON: one JSON object per line in each direction, with each response carrying the id of the request it answers.
JSON rather than a compact binary encoding is a deliberate trade. When dwed itself is the thing misbehaving, the protocol has to stay drivable by hand — and being able to type at it and read what comes back is worth more than the bytes a binary framing would save:
$ nc 10.0.0.5 4820
{"id":1,"op":"info"}
{"id":1,"ok":{"reply":"info","protocol_version":1,"dwed_version":"0.1.0",...}}
Framing #
A request is an object with an id and an op, plus that op's arguments inline:
id is chosen by the client and echoed back untouched. Requests on one connection are answered in order; concurrency comes from opening more connections, or from detaching work.
A response carries the same id and exactly one of ok or error:
The reply field names the shape of the payload. It is there because several replies carry the same fields — exec and job.output both have stdout, stderr and an optional exit — and a reader should never have to guess which one it is holding from shape alone.
A request that does not parse is still answered, with id: 0 and a bad_request error. Silence would leave a client waiting on an id that is never coming, which presents as a hang — the one symptom hardest to tell apart from the bug being investigated.
Bytes #
Every field carrying payload bytes is base64: file contents, and captured stdout/stderr alike.
Output is base64 rather than a JSON string because a guest command's output is not guaranteed to be valid UTF-8, and a tool that mangles a binary is worse than one that refuses it. Clients decode and write raw bytes back out, so the encoding never reaches whoever is driving the tool.
Operations #
exec #
Run a command. Synchronous unless detach is set.
| Field | ||
|---|---|---|
argv | required | Program and arguments. Executed directly — not through a shell. |
cwd | optional | Working directory. |
env | optional | Extra environment, as [name, value] pairs, on top of the service's own. |
stdin | optional | Bytes written to the child's stdin, which is then closed. |
detach | optional | Return a job handle immediately instead of waiting. |
Replies exec with exit (null if signalled), signal, stdout, stderr and truncated; or job with a job handle when detach was set.
Both output streams are captured concurrently. A child that fills one pipe while the other goes undrained would otherwise deadlock, and "the command hung" is the least useful thing a debugging tool can report.
job.list #
No arguments. Replies job_list with a jobs array of {job, argv, running, exit, signal, started}.
Jobs outlive the connection that created them. They do not outlive dwed itself.
job.output #
| Field | ||
|---|---|---|
job | required | The handle. |
since_stdout | optional | Resume stdout from this byte offset. |
since_stderr | optional | Resume stderr from this byte offset. |
Replies job_output with stdout, stderr, stdout_next, stderr_next, running, exit, signal and truncated.
The *_next values are what to pass as since_* on the following call. Cursors are the caller's to keep: dwed has no sessions and cannot distinguish two callers, so a server-side cursor would have concurrent readers consuming each other's output. An offset past the end is clamped rather than rejected.
job.signal #
{job, signal} — deliver a signal to a running job. Replies done. A job that has already exited gives not_running.
file.read #
{path} — replies file_read with bytes and mode.
file.write #
{path, bytes, mode?} — replies done. mode is applied after writing.
info #
No arguments. Replies info:
| Field | |
|---|---|
protocol_version | The version dwed speaks. |
dwed_version | Its own release. |
boot_id | Distinguishes one boot from the next. |
uptime | Seconds since boot. |
boot_id is the field worth using. It lets a client tell "the machine rebooted under me" from "my connection dropped" — two situations that look identical from the socket and mean entirely different things.
Errors #
code | |
|---|---|
bad_request | Malformed JSON, an unknown op, or empty argv. |
io | An underlying system call failed. Carries errno. |
no_such_job | No job with that handle. |
not_running | The job has already exited. |
errno is carried separately from the message so a client can match on the cause rather than parse English.
Transports #
vsock is the default, on port 4820. It needs no networking in the guest — which matters, because a machine whose networking is part of what broke is squarely one of the cases DWE exists for. dwed binds VMADDR_CID_ANY: a guest does not reliably know its own context id, and does not need to.
TCP is available on the same port but is opt-in (dwed --tcp 0.0.0.0:4820). Listening by default would hand the machine to anyone who can route to it, which is more than starting a service should quietly do given there is nothing behind it.
The protocol is identical over either.
Versioning #
PROTOCOL_VERSION is bumped whenever any wire type changes shape. A client compares it against info and reports a mismatch rather than misparsing a response it half understands.
There is no negotiation and no compatibility window. Both halves ship from one repository and are built together; a version check is there to give a clear error, not to bridge a gap.
Building a Peios image
Peios / Peiso / Building images
peiso is the Peios image builder. Given a declarative spec, it builds a bootable Peios image tree from scratch: it takes a release's worth of packages and produces an artifact that a machine can boot.
It is a v1 tool with a deliberately narrow scope. It does not resolve packages, fetch payloads, or lay out a root — that is peipkg-compose's job, and peiso calls it for exactly that work. peiso owns everything above the package root: the boot machinery that turns a directory of installed software into something bootable.
This page explains what a package root is versus what a bootable image is, the chain of artifacts peiso emits between the two, how you invoke it, and the limits of its v1 scope.
Where peiso sits — above compose #
The two tools split the work at a well-defined boundary.
peipkg-compose builds the package root: a directory tree with every package's payload laid out at its installed paths, a seeded peipkg state database, and each repository written back as a .repo file. The result is a valid peipkg system that can manage itself — but it is only that. Compose's contract stops at delivering a valid peipkg root: no bootloader, no packed initramfs, no kernel image, no live-boot wiring. Those belong to whatever assembles the image around it. See Composing a root for the full contract.
peiso is that outer assembler. It shells out to peipkg-compose build to produce the root, then layers boot machinery on top of it. The division of responsibility is:
- compose = the package stage. Resolve, verify, and lay out software into a root directory. Offline, deterministic, no boot concerns.
- peiso = the bootable-image stage. Take that root and produce an initramfs, a squashable rootfs, a kernel image, and a bootable medium.
Because the boot stage runs Peios' own applets — and runs them inside the composed root — peiso needs privilege where compose needs none. This requirement is covered below.
The output chain #
peiso builds an image as a chain of artifacts, each consuming earlier ones. The initramfs cpio is packed inside the composed root, so both the squashfs (a squash of the whole root) and the UKI (kernel + cpio + cmdline) contain it; the ISO then carries the UKI and the squashfs side by side.
flowchart LR
A["package root<br/>(peipkg-compose)"] --> B["initramfs cpio<br/>(mkirf, in-root)"]
B --> C["rootfs.squashfs<br/>(optional)"]
B --> D["UKI — one EFI binary<br/>(mkuki, in-root)"]
C --> E["bootable UEFI live ISO<br/>(optional)"]
D --> E
-
Package root. peiso calls
peipkg-compose buildon the spec's manifest. A single multi-root manifest can compose both the main system root and a nested initramfs root in one pass. (An older spec form gave the initramfs root its own separate manifest to compose; that form is transitional — prefer the single multi-root manifest.) -
Initramfs cpio. peiso chroots into the composed root and runs the root's own
mkirfapplet to pack the initramfs root into a cpio archive. Running the shipped mkirf in its native environment means the tool that builds the initramfs is the same one the running system uses — no divergence between what is tested and what runs. (Before this step, extra files may be dropped straight into the initramfs root via the spec's fileinjectlist — a temporary bypass of packaging, used for things like the live-boot hook until they are properly packaged.) -
Squashfs rootfs (optional). peiso squashes the whole composed root into a read-only squashfs image (the reference spec names it
rootfs.squashfs). squashfs preserves existing extended attributes — where KACS security descriptors ride — so the live rootfs is byte-identical to an installed system. The image is written outside the root tree so it can never contain itself. -
UKI (optional). peiso chroots in again and runs the root's
mkukiapplet to bundle the kernel, the initramfs cpio, and the kernel command line into a Unified Kernel Image — one EFI binary that UEFI firmware boots directly, with no separate bootloader. -
Bootable ISO (optional). peiso emits
peios.iso: a UEFI-bootable live image carrying the UKI on an EFI System Partition (ESP) and the rootfs squashfs in its data area, so the running system reads the OS off the medium rather than fitting the whole thing in RAM.
Steps 3, 4, and 5 are optional and driven by the spec — a build can stop at the packed root plus initramfs, or run all the way to an ISO.
What lands on disk #
A full build leaves, among other things:
- the composed
root/tree (the package root plus the packed initramfs cpio inside it); - the rootfs squashfs (named by
[squashfs].out;rootfs.squashfsin the reference spec) — written besideroot/, not inside it; - the UKI at its ESP fallback path, e.g.
boot/efi/EFI/BOOT/BOOTX64.EFI; peios.iso— the bootable UEFI live medium.
How you run it #
peiso has a single verb:
sudo peiso build [spec.toml]
It is driven by a peiso.toml spec (schema 1) that declares the whole image in one file — the manifest to compose, how the initramfs is packed, and the optional squashfs, UKI, ISO, registry-seed, and feature stages. The spec path defaults to peiso.toml.
The build must run as root — it chroots into the composed root to run the Peios-native applets (mkirf, mkuki), and chroot is privileged; the full rationale is in Running a build.
See Running a build for the command in detail, and The build spec for every field of peiso.toml.
Scope and v1 caveats #
peiso is at v1, and this page describes its current behaviour; the surface is not frozen and may change.
- Distribution / live-image focused. The chain above is built around producing a bootable live image (UKI + off-RAM squashfs on a UEFI-bootable ISO). This is the path v1 supports.
- A single spec schema. One
schema = 1spec shape, parsed strictly. - Optional stages. squashfs, UKI, ISO, registry seeds, and feature enablement are each opt-in through their spec sections; a minimal build composes the root and packs the initramfs and stops.
- Transitional mechanisms exist. The file-
injectpackaging bypass and the older separate-compose initramfs manifest are both temporary mechanisms on the way to fully-packaged inputs. They are documented where they are used, and labelled as temporary.
Note that v1 does no signing: peiso layers boot machinery; it does not create keys or sign binaries. Security properties come from the packages and their preserved security descriptors, not from anything peiso adds.
Where to go next #
- To understand each stage in order — the composes, the chroot, the layering that lets the squashfs embed the initramfs and the ISO carry both the UKI and the squashfs — read The build pipeline.
- To run a build — the command, root requirement, and how peiso finds
peipkg-compose— read Running a build. - For every field of
peiso.toml, section by section, read The build spec. - For the package stage beneath peiso — how the root is resolved, verified, and laid out — read Composing a root.
Quick start
Peios / Peiso / Building images
This page runs the smallest build peiso can do. At the end you have a composed Peios package root on disk with the initramfs cpio packed inside it, produced by one sudo peiso build against a three-table peiso.toml. The squashfs, UKI, and ISO stages are opt-in and stay off here — see The build pipeline for the full chain.
Prerequisites #
peiso orchestrates other tools and composes real packages, so all three of these are hard requirements — there is no build without them.
- Root. The build chroots into the composed root to run its shipped applets, so it must run as root. Run it under
sudo. peipkg-composeonPATH. peiso shells out to it to compose the root. If it is installed somewheresudocannot see, setPEISO_COMPOSEto its path — the full lookup chain is in Running a build.- A package source. peiso does not fetch or build packages. You need built
.peipkgfiles (a package farm) and a multi-root compose manifest that lays them out: it must produce both the main root and a nested initramfs root, and the packages it installs must includepeiosutils(which ships themkirfapplet the build runs inside the root) andfsbase(which owns the/system/bootdirectory the cpio is written into). The Peios repository'sdist/prod/manifest.tomlis the reference manifest, composed from the repository's own farm; writing manifests is covered in Composing a root.
No other host tools are needed for this build. mksquashfs, xorriso, and friends only come into play when you enable the optional stages.
Lay out the build directory #
Work in a directory that holds your compose manifest. With the reference setup that is the repository's dist/prod/ directory, which already contains a full peiso.toml; to follow this page from scratch, use a fresh directory containing:
manifest.toml— your multi-root compose manifest;- the package farm it references via its
local_packagesglobs; peiso.toml— written in the next step.
Write the minimal spec #
Create peiso.toml next to the manifest:
= 1
[]
= "manifest.toml"
= "root"
[]
= "boot/initramfs"
= "system/boot/initramfs.cpio.gz"
This is the whole spec — schema, [root], and [initramfs] are the only required tables. manifest and out are resolved relative to the spec file's own directory, so the build behaves the same wherever you invoke it from. dir and cpio are relative to the composed root: dir names the nested initramfs root your multi-root manifest produces, and cpio is where mkirf writes the packed archive inside the root. Every field, including the optional ones this spec omits, is documented in The build spec.
Run the build #
build takes one optional argument, the spec path, and it defaults to peiso.toml in the current directory — so the bare command above is enough. peiso prints one progress line per stage, with your absolute paths substituted:
peiso: composing root -> /path/to/your/build/dir/root
peiso: packing initramfs (chroot /path/to/your/build/dir/root) -> /system/boot/initramfs.cpio.gz
peiso: built /path/to/your/build/dir/root
peipkg-compose's own output streams between the first two lines. On a rebuild, a peiso: cleaning … line appears first: peiso deletes the previous root/ and composes fresh every time, so never keep anything you care about inside it. The command exits 0 on success.
If it fails, the error is printed as peiso: <err> and the exit code is 1. The three you are most likely to hit first:
build chroots into the composed root and must run as root (try: sudo peiso build)— you forgotsudo.peipkg-compose not found on PATH or next to peiso (set PEISO_COMPOSE to override)— see the environment section of Running a build;sudo'ssecure_pathis the usual culprit./usr/bin/mkirf not in the composed root … (is peiosutils composed in?)— your manifest did not composepeiosutilsin, so the root has nomkirfto pack the initramfs with.
What landed on disk #
The build leaves one artifact tree, rooted at the spec's out directory:
root/— a complete, valid peipkg package root: every package's files at their installed paths, plus a seeded peipkg state database.root/boot/initramfs/— the nested initramfs root, composed in the same pass by the multi-root manifest.root/system/boot/initramfs.cpio.gz— the packed initramfs cpio, exactly where an installed system stores its own.
Confirm the cpio exists:
There is no rootfs squashfs, no UKI, and no peios.iso — those stages only run when their spec tables are present, and this spec left them out.
Where to go next #
- Understand what just ran. The build pipeline walks every stage in order — the compose, the chroot, and the optional stages this build skipped.
- The command in full. Running a build covers the exit codes, host-tool checklist, environment, and the
dist/prod/Makefile workflow that drives real builds. - Grow the spec. Add
[squashfs],[uki], and[iso]tables to carry this build all the way to a bootable live ISO — every field is in The build spec.
The build pipeline
Peios / Peiso / Building images
A single sudo peiso build <spec> turns one declarative build spec into a bootable image. It does this by running a fixed, ordered sequence of stages — compose the root, pack the initramfs, stage first-boot state, squash the rootfs, bundle a UKI, author an ISO. This page walks each stage in the order peiso runs them and explains why the order matters.
Two core ideas #
Two ideas underpin every stage that follows.
Compose does the packages; peiso does the boot machinery. peiso does not resolve a package set or lay out a root itself — that is peipkg-compose's job, and peiso shells out to it. What peiso adds is everything compose deliberately does not do: pack the initramfs, seed first-boot registry and feature state, produce a rootfs image, bundle a Unified Kernel Image, and author a bootable ISO. Compose stops at delivering a valid peipkg root; peiso begins there.
The chroot model lets peiso run Peios-native applets against the composed root. Several stages are not host tools at all — they are Peios boot applets (mkirf, mkuki) that ship inside the composed root, at /usr/bin/mkirf and /usr/bin/mkuki. peiso chroots into the composed root and runs them there, so they operate on the tree exactly as they would on a live, booted system. The full /usr paths are deliberate: image construction happens before the root-level StrataFS views exist, so Peiso self-hosting addresses package storage directly. This is why the build is privileged, and why the applets take root-relative paths. peiso never carries its own copy of these tools; it uses the ones the image was composed with.
Determinism runs through the whole pipeline. The spec's source_date is threaded into every build-stamped timestamp — compose derives its output stamps from it, and peiso pins the squashfs timestamps from it — so the same spec, manifest, lock, and packages yield a byte-reproducible image.
The stages, in order #
The stages below are the exact sequence peiso runs. Optional stages (marked optional) run only when their spec section is present.
1. Preconditions #
peiso must run as root (effective UID 0), because it chroots — it checks this first, and the failure message and full rationale are covered in Running a build.
It then locates the peipkg-compose binary. The search order is: the PEISO_COMPOSE environment variable (overrides everything), else the binary on PATH, else a sibling of the peiso binary itself — the two are installed together, and sudo's secure_path routinely drops the directory (e.g. a Go GOBIN) where the sibling sits, so peiso looks right next to itself as a fallback.
2. Clean the output root #
3. Compose the main root #
peiso shells out to the package stage:
peipkg-compose build <[root].manifest> --update --out <[root].out>
This is where all package resolution and layout happens — see Composing a root. --update always re-resolves, so a stale manifest.lock.toml (left after editing the manifest or republishing a package) never blocks the build with a digest mismatch; peiso rebuilds against the current manifest and farm.
In the normal v1 setup the manifest is multi-root: one compose produces both the main system root at [root].out and the nested initramfs root at <out>/boot/initramfs in the same pass. That single tree is why peiso needs none of the live consumer's cross-root transaction machinery here.
4. Compose a separate initramfs root — legacy, normally skipped #
peiso composes the initramfs root as its own root only if [initramfs].manifest is set:
peipkg-compose build <[initramfs].manifest> --update --out <[root].out>/<[initramfs].dir>
This is the older, pre-multi-root form. With a multi-root main manifest the initramfs root is already nested in place from stage 3, so this stage is skipped. Treat [initramfs].manifest as legacy; new specs leave it unset and rely on the multi-root manifest.
5. Inject files into the initramfs root — temporary #
If [initramfs].inject has entries, peiso copies each src file straight into the initramfs root at dest, forcing the declared mode (a chmod after write, so the executable bit survives umask — the initramfs prelude execs hooks via their shebang and they must stay executable in the cpio). If a mode is omitted the source file's permission bits are preserved.
[root].inject does the same for the main system root, in the same stage. It runs before every stage that reads the root, so an injected file is visible to the registry-seed staging, the squashfs and the UKI — which makes the common pairing possible: inject a .reg master into usr/share/regim/ and name it in [registry] autoapply.
Both are a bypass of packaging: they drop files into the tree without a package owning them, so peipkg will not upgrade, verify or remove them. For content that images should get properly, a package is the answer — the live-boot hook started here and is now packaged (live-boot-irf ships it and a cross-root dependency pulls it into the initramfs root during compose). What stays is the composing artifact's own contributions: an image-specific registry seed, a bring-up affordance you want deletable by editing one spec rather than uninstalling a package.
6. Pack the initramfs #
peiso chroots in and runs the composed root's own mkirf:
chroot <[root].out> /usr/bin/mkirf [--exclude <GLOB>]... /<[initramfs].dir> /<[initramfs].cpio>
This produces the gzip cpio (in the example spec, at root/system/boot/initramfs.cpio.gz) — the early-boot environment. The source and output paths are root-relative in the spec, so inside the chroot they are simply /<dir> and /<cpio>. --exclude globs keep things the early boot does not need out of the cpio (the peipkg database, for example). See mkirf for what goes into the image and how it is assembled.
This runs before the squashfs (stage 9) deliberately, so the rootfs image contains this cpio — exactly as an installed system stores its initramfs under /system/boot. peiso does not create that destination directory; the fsbase package, composed into the main root, owns it.
7. Stage registry seeds #
If [registry].autoapply lists any seed masters, peiso copies each one from the composed root's vendor library at /usr/share/regim/ into the composer-owned queue at /lcl/policy/autoapply.d/, and drops a small autorun script (10-apply-seeds.sh) that drains the queue with reg apply --once-delete on boot. peinit runs the script every boot; it is a no-op once the queue is empty.
The point of the two directories is curation: packages may drop seed masters into /usr/share/regim/, but only peiso — the image — decides which of them auto-apply, by copying them into the /lcl/policy queue, which sits off peipkg's compiled-in allowlist so no package can write there. Nothing a package ships becomes boot-active unless this image listed it. This runs before the squashfs so the staged seeds ride into the rootfs image.
8. Stage features #
If [features].enable lists any features, peiso writes a self-removing first-boot autorun script (20-features.sh, ordered after the seed-apply script) containing a feat add <name> line per entry. peinit runs it after registryd is up and before Phase 2 enumerates services, so the services a feature creates start the same boot.
The script removes itself after running (rm -- "$0"). On a persistent install that whiteout is durable, so it runs once. On the live read-only image the removal is an ephemeral tmpfs-overlay whiteout, so the script reappears and re-runs each boot — re-creating the services the fresh tmpfs registry would otherwise lose. As with the seeds, the selection lives in the image spec, not in any package: a package ships a feature's lifecycle scripts, but only the image turns it on.
9. Squashfs — optional #
If [squashfs] is present, peiso squashes the whole composed root into a read-only rootfs image:
mksquashfs <[root].out> <tmp> -noappend -xattrs -mkfs-time <t> -inode-time <t> [-comp <compression>]
The timestamps <t> are fixed from source_date (falling back to epoch 0) for reproducibility. -noappend forces a fresh image rather than appending to any existing one. -xattrs preserves existing extended attributes — KACS security descriptors ride in them — so the live rootfs carries the same security metadata an installed system would; it does not sign or add anything.
The image is written to a temp path that is a sibling of the root, outside root/, and renamed into [squashfs].out on success. Writing outside root/ is what lets the squash be of the whole root with no excludes even when out itself lives inside the root tree: the growing output can never capture itself. (src and out must therefore share a filesystem, since the finalisation is a rename.) The result is byte-identical to an installed system — it even contains the initramfs cpio from stage 6.
10. UKI — optional #
If [uki] is present, peiso chroots in and runs the composed root's mkuki:
chroot <[root].out> /usr/bin/mkuki --kernel /usr/lib/modules/<release>/vmlinuz-<release> --initramfs /<[initramfs].cpio> --cmdline "<text>" --out /<[uki].out>
This bundles the kernel (resolved from /usr/lib/modules/<release>/vmlinuz-<release> inside the root), the initramfs cpio from stage 6, and the kernel cmdline into a single EFI binary that UEFI firmware boots directly — no bootloader. The cmdline comes from [uki].cmdline if given, otherwise the trimmed contents of [uki].cmdline_file (read on the host and passed as a literal --cmdline, so the chroot needs no in-root file). Reading from the composed root's own cmdline file keeps build-time and runtime agreement — see mkuki. Like mkirf, mkuki is a Peios Dynamic-Boot applet, which is why it runs in the chroot against the tree that ships it.
11. ISO — optional #
If [iso] is present, peiso authors a UEFI, USB/block-bootable ISO. This stage runs on the host, not in the chroot — xorriso is a build-host tool and the ISO is a host-side artifact, not a Peios boot tool.
peiso builds a FAT32 ESP image from the ESP tree at [iso].source (sizing a zero-filled file, formatting it with mkfs.vfat -F 32, and populating it with mcopy — no mount, no loop device), then has xorriso append that image as a GPT ESP partition (type 0xEF). Firmware treats the .iso as a disk, reads the GPT, and boots the ESP's UKI at [iso].efi_boot. If a squashfs was built, it is placed into the ISO9660 data area (hard-linked when it shares a filesystem, so a multi-GB image is never duplicated) as a file the live system's mount-root hook reads off the medium by volume label — keeping the whole OS off RAM. -iso-level 3 lifts ISO9660's 4 GB single-file limit, so a multi-gigabyte squashfs fits as one file. The path is UEFI-only and USB/block by design: an El Torito EFI entry pointing at the appended partition is written but not relied on, and there is no BIOS/isolinux path at all — dd the .iso to a USB stick and firmware boots it as a disk.
External tools #
peiso is an orchestrator: most of the work is done by tools it shells out to. Some run on the build host; the Peios boot applets run inside the chroot against the composed root.
| Tool | Where it runs | Stage | Role |
|---|---|---|---|
peipkg-compose | host | 3, 4 | Resolve and lay out the package-owned root(s). |
chroot → /usr/bin/mkirf | inside the chroot | 6 | Pack the initramfs cpio (Peios applet shipped in the root). |
mksquashfs | host | 9 | Build the read-only rootfs image. |
chroot → /usr/bin/mkuki | inside the chroot | 10 | Bundle the UKI (Peios applet shipped in the root). |
xorriso | host | 11 | Author the ISO9660 image and append the ESP partition. |
mkfs.vfat | host | 11 | Format the FAT32 ESP image. |
mcopy (mtools) | host | 11 | Populate the ESP image without mounting. |
The mkirf and mkuki invocations are chroot calls into the composed root; every other tool runs directly on the host.
Why the stages run in this order #
The dependencies fix the order. Compose must produce the tree before anything can operate on it. The initramfs is packed before the squashfs so the rootfs image contains it. Registry seeds and feature scripts are staged before the squashfs so they ride into the rootfs image. The UKI needs the packed initramfs cpio, so it follows stage 6 (and follows the squashfs in sequence). The ISO needs the UKI. Read top to bottom: each stage consumes what the stages above it produced.
Where to go next #
For the full field-by-field reference of every spec section named here, read The build spec.
For how to invoke a build and what it prints, read Running a build.
Running a build
Peios / Peiso / Building images
peiso build takes a declarative spec and produces a bootable Peios image tree from it — it composes the package root, packs the initramfs, and layers on the optional squashfs, UKI, and ISO stages. This page covers running that command: its one argument, why it needs root, what it needs on the host, and how the dist/prod/ workflow invokes it. For what each stage does, see The build pipeline; for the spec it reads, see The build spec.
Synopsis #
peiso build [spec.toml]
peiso -h | --help | help
build is peiso's only working verb. It takes one optional positional argument — the path to the build spec — and defines no flags; anything after the spec path is ignored. The spec path is the only input to the command; everything else about the build is declared inside the spec.
When the positional is omitted, the spec path defaults to peiso.toml in the current directory:
$ cd dist
$ sudo peiso build # builds ./peiso.toml
$ sudo peiso build peiso.toml # the same, spelled out
peiso -h, peiso --help, and peiso help all print the usage text and exit. Running peiso with no arguments at all is a usage error (it prints usage and exits 2).
The build must run as root #
The build chroots into the composed root to run Peios' own applets — mkirf to pack the initramfs, and mkuki to bundle the UKI — in their native environment. chroot is privileged, so peiso requires an effective UID of 0.
peiso checks this first, before it composes anything, and fails fast with a message that tells you what to do, rather than composing for minutes and then failing at the chroot:
peiso: build chroots into the composed root and must run as root (try: sudo peiso build)
Run it under sudo (or as root):
sudo peiso build peiso.toml
This root requirement is the one operational difference from peipkg-compose, which needs no privilege because it only ever writes inside its output directory. peiso needs privilege precisely because the boot stage runs the shipped applets inside the root.
Exit status #
| Code | Meaning |
|---|---|
0 | The build succeeded — the image tree (and any optional artifacts) were produced. |
1 | The build failed. The error is printed to stderr as peiso: <err> — a missing/invalid spec, not running as root, peipkg-compose not found, a compose failure, a missing in-root applet, or any stage that errored. |
2 | A usage error — no command at all, or an unknown command — with the usage text printed to stderr. |
Prerequisites #
A build uses two distinct sets of tools: programs peiso runs on the host, and applets it runs inside the composed root by way of chroot. Which stages use each are covered in The build pipeline; this section is the checklist.
Host tools #
These must be present on the build host (on PATH, unless noted):
| Tool | Package | Used for |
|---|---|---|
peipkg-compose | peipkg | Composing the package root(s). peiso shells out to it. Located via PATH, a peiso-binary sibling, or PEISO_COMPOSE — see Environment. |
mksquashfs | squashfs-tools | Building the rootfs squashfs from the composed root (the squashfs stage). |
xorriso | xorriso | Emitting the bootable peios.iso (the ISO stage). |
mkfs.vfat | dosfstools | Building the FAT32 ESP image the ISO carries (the ISO stage). |
mcopy | mtools | Copying the UKI into that ESP image (the ISO stage). |
The last three are only needed if the spec's ISO stage is enabled; mksquashfs only if the squashfs stage is. A minimal spec that stops at the packed root needs only peipkg-compose.
In-root applets #
Two applets are run inside the composed root via chroot, so they are not host tools — they must be present in the root itself, which means the spec's manifest must install the packages that ship them:
| Applet | Path in the root | Used for |
|---|---|---|
mkirf | /usr/bin/mkirf | Packing the initramfs root into a cpio archive. Always run. |
mkuki | /usr/bin/mkuki | Bundling the Unified Kernel Image (the UKI stage). |
Running the shipped applets in their own environment is deliberate: the tool that builds the initramfs is the same one the running system carries, so there is no divergence between what is tested and what runs. If mkirf is missing from the composed root, the build fails with a hint that the applet package (peiosutils) was not composed in.
These are package-storage paths deliberately. Peiso self-hosts before the root-level StrataFS views are mounted, so it must not depend on /bin or another projected view. On x86-64 the composed root must still carry the base-filesystem /lib64 → usr/lib/x86_64-linux-peios mapping required by the ELF ABI to start dynamically linked applets.
Environment #
peiso reads a single environment variable:
| Variable | Effect |
|---|---|
PEISO_COMPOSE | Overrides the location of the peipkg-compose binary. When set, peiso uses it verbatim and skips the search below. |
When PEISO_COMPOSE is unset, peiso finds peipkg-compose by:
- looking it up on
PATH; then - falling back to a sibling of the peiso binary itself — the two are installed together (
go installdrops both inGOBIN), andsudo'ssecure_pathroutinely dropsGOBINfromPATH, hiding the sibling installed next to peiso.
If it is found by none of these, the build fails:
peiso: peipkg-compose not found on PATH or next to peiso (set PEISO_COMPOSE to override)
No other environment variables are read.
What you supply #
Running a build takes two inputs, both authored by you:
- A
peiso.tomlspec — the declaration of the whole image: the manifest to compose, how the initramfs is packed, and the optional squashfs, UKI, ISO, registry-seed, and feature stages. Every field is documented in The build spec. - The peipkg-compose manifest the spec references — the package set and repositories that compose into the root. This is an ordinary compose manifest; see Composing a root.
Everything else — the initramfs cpio, the squashfs, the UKI, the ISO — is produced by the build. peiso owns its output tree as a rebuildable artifact and clears a prior one before each run, so a rebuild always starts clean.
The dist workflow #
The reference workflow lives in the repository's dist/prod/ directory, driven by its Makefile. The root: target is the canonical way to run a build, and it handles two operational details for you — locating peiso and elevating to root:
PEISO :=
: The target resolves peiso as the invoking user, while PATH is still intact, and then sudo-runs it by absolute path. That indirection matters: sudo's secure_path drops ~/go/bin, so a bare sudo peiso would not find peiso (nor the peipkg-compose it calls). Resolving the absolute path first sidesteps that entirely.
The whole build is then:
cd dist/prod
make root
which expands to sudo /abs/path/to/peiso build peiso.toml against the dist/prod/peiso.toml spec and its manifest.toml (the repo prerequisite publishes the medium's offline package repository first, for the spec's [[iso.include]]). The other dist/prod/ targets boot the result under QEMU: make boot boots peios.iso under OVMF — the real UEFI, GPT-ESP, UKI path, the same one dding the ISO to a USB stick exercises on metal — with boot-break and boot-quiet variants, and make boot-install / make boot-installed drive the installation round trip against a scratch disk (make clean-disk resets it).
What a build leaves behind #
A full build (all stages enabled) produces the chain of artifacts described in The build pipeline. In brief:
- the composed
root/tree — the package root with the packed initramfs cpio inside it; - the rootfs squashfs (named by
[squashfs].out) — written besideroot/, not inside it; - the UKI at its ESP fallback path, e.g.
boot/efi/EFI/BOOT/BOOTX64.EFI; peios.iso— the bootable UEFI live medium.
The squashfs, UKI, and ISO are each optional and driven by their spec sections; a minimal build stops at the composed root plus the initramfs cpio.
See also #
- The build spec — every
peiso.tomlfield the command reads. - The build pipeline — what each stage of the run does.
- Composing a root — the manifest the spec points at.
The build spec
Peios / Peiso / Reference
peiso.toml is the declarative TOML spec that drives peiso. A single file describes the whole bootable image: the peipkg-compose manifest to build the package root from, how the initramfs is packed, and the optional squashfs, UKI, ISO, registry-seed and feature stages that the build pipeline layers on top. This page documents schema version 1 — the only schema peiso accepts — field by field.
The spec is passed to peiso positionally and defaults to peiso.toml in the current directory:
sudo peiso build [spec.toml]
Three properties hold across the whole file:
- TOML, parsed strictly. An unknown key anywhere in the spec is a hard error, not a warning — peiso rejects the first undecoded key by name. A mistyped field fails the build rather than being silently ignored.
schemais checked first. The spec must declareschema = 1; any other value (or a missingschema, which reads as0) fails withunsupported schema.- Relative paths resolve against the spec file's own directory — never the current working directory. So a build behaves the same wherever you invoke it from. There are two kinds of path handling, and each field uses one of them:
- Spec-relative paths (
root.manifest,root.out,initramfs.manifest,squashfs.out,uki.cmdline_file,iso.source,iso.out,inject.src) are made absolute against the spec's directory. An already-absolute value is kept as given. - Root-relative paths (
initramfs.dir,initramfs.cpio,uki.out,iso.efi_boot,inject.dest) are cleaned and kept relative (any leading/is stripped). These double as chroot-absolute paths inside the composed root, so they are validated to not escape with..(see Validation).
- Spec-relative paths (
A complete example #
A full spec that composes a root, packs the initramfs, squashes the rootfs, builds a UKI, emits a bootable ISO, and enables a feature on first boot:
= 1
= "2026-06-22T00:00:00Z" # pins reproducible build timestamps
# Let packages declaring `special_system_package` compose outside the layout
# rules. Needed by any image carrying the base-filesystem package, whose whole
# job is to lay down the mountpoint tree those rules protect.
= true
# The whole image. manifest.toml is multi-root — it composes the main system
# root into `out` and the nested initramfs root (at out/boot/initramfs) in one
# pass, so [initramfs] below needs no manifest of its own.
[]
= "manifest.toml"
= "root"
# How mkirf packs the already-composed initramfs root into a cpio. `dir` and
# `cpio` are root-relative: inside the chroot they are mkirf's /<dir> source and
# /<cpio> output. The peipkg database isn't needed at early boot, so it's excluded.
[]
= "boot/initramfs"
= "system/boot/initramfs.cpio.gz"
= ["var/state/peipkg", "lcl/conf/peipkg"]
# Read-only rootfs image, squashed from the WHOLE composed root. `out` is written
# OUTSIDE root/ so the image can never contain itself.
[]
= "rootfs.squashfs"
= "zstd"
# Unified Kernel Image: kernel + initramfs cpio + cmdline in one EFI binary.
# cmdline_file is read from the composed root, so build-time and runtime agree.
[]
= "root/usr/share/live-boot/cmdline"
= "boot/efi/EFI/BOOT/BOOTX64.EFI"
# Bootable UEFI ISO built from the ESP tree at `source`, with the UKI marked as
# the EFI boot image at `efi_boot` (relative to source).
[]
= "root/boot/efi"
= "EFI/BOOT/BOOTX64.EFI"
= "peios.iso"
= "PEIOS"
# Features this image enables on first boot (feat add <name>).
[]
= ["dynamic-boot"]
[squashfs], [uki], [iso], [registry] and [features] are all optional — a minimal spec is just schema, [root] and [initramfs], which composes the root and packs the initramfs.
Top-level fields #
| Key | Type | Required | Meaning |
|---|---|---|---|
schema | int | yes | Spec schema version. Must be 1; any other value fails. |
source_date | string | no | A fixed build timestamp for reproducible artifacts, passed through to mksquashfs. Empty (or omitted) means epoch 0. The format is not validated by peiso — the example uses an RFC 3339 timestamp, mirroring the compose manifest's source_date. |
bypass_path_restrictions | boolean | no | Permits packages that declare special_system_package to compose payloads outside the package layout rules — the base-filesystem package that mints the mountpoint tree being the case that needs it. Passed through to peipkg-compose as --dangerously-bypass-path-restrictions. Defaults to false. It exempts nothing that has not declared itself special: the package proposes, and this line is how the image decides. |
[root] — the package root (required) #
The main system root, composed by shelling out to peipkg-compose build. Both keys are required.
| Key | Type | Required | Meaning |
|---|---|---|---|
manifest | string (spec-relative) | yes | The peipkg-compose manifest to build the root from. A multi-root manifest composes both the main system root and the nested initramfs root in one pass. |
out | string (spec-relative) | yes | The directory to compose into and chroot into. peiso owns this as a rebuildable artifact and clears it before each build (a single clean that also clears the nested initramfs root). |
inject | array of tables | no | A packaging bypass. Files copied straight into the composed main root after compose. See below. |
[[root.inject]] #
Each entry copies one file into the composed main root, after compose and before every stage that reads the root — so an injected file is visible to the registry-seed staging, the squashfs, and the UKI. src and dest are both required.
| Key | Type | Required | Meaning |
|---|---|---|---|
src | string (spec-relative) | yes | The source file to copy. |
dest | string (root-relative) | yes | The destination inside the main root. Must not escape with ... |
mode | string (octal) | no | Permission bits as an octal string (e.g. "0644"). Empty (or omitted) preserves the source file's mode. |
An injected file has no package owner, so peipkg will not upgrade, verify or remove it. That cost is the reason to prefer a package for anything images should get properly. What it buys is a home for the composing artifact's own contributions — an image-specific registry seed, a boot script — which is the same authority peiso already exercises when it stages the seed queue and the autorun script into /lcl/policy.
The common pairing is a seed: inject a .reg master to usr/share/regim/<name> and name it in [registry] autoapply. The inject runs first, so by the time the queue is built the seed is indistinguishable from one a package shipped.
[initramfs] — packing the initramfs (required) #
Describes how peiso packs the initramfs root into a cpio archive using the composed root's own mkirf applet, run inside the chroot. dir and cpio are required; the rest are optional.
| Key | Type | Required | Meaning |
|---|---|---|---|
dir | string (root-relative) | yes | The initramfs root, relative to root.out (e.g. boot/initramfs). Inside the chroot this is mkirf's /<dir> source. Must not escape the root with ... |
cpio | string (root-relative) | yes | The cpio output path, relative to root.out (e.g. system/boot/initramfs.cpio.gz). Inside the chroot this is mkirf's /<cpio> output. Must not escape the root with ... |
exclude | array of strings | no | Globs passed to mkirf --exclude, relative to the initramfs root — paths omitted from the cpio (e.g. the peipkg database, not needed at early boot). |
manifest | string (spec-relative) | no | Legacy — normally unused. The older separate-compose form: a manifest that composes the initramfs root on its own. It is normally absent because a multi-root root.manifest already produces the nested initramfs root, and this section then only describes how mkirf packs it. When present, peiso composes it into root.out/<dir> as a second, separate compose. |
inject | array of tables | no | Temporary — a packaging bypass. Files copied straight into the initramfs root before mkirf runs, a stopgap for landing files (e.g. the live-boot hook) without packaging them. See below. |
[[initramfs.inject]] (temporary) #
Each entry copies one file into the initramfs root before it is packed. This is a transitional mechanism — the intended path is to ship such files in a package so a cross-root dependency pulls them into the initramfs root during compose. src and dest are both required.
| Key | Type | Required | Meaning |
|---|---|---|---|
src | string (spec-relative) | yes | The source file to copy. |
dest | string (root-relative) | yes | The destination inside the initramfs root. Must not escape with ... |
mode | string (octal) | no | Permission bits as an octal string (e.g. "0755"). Empty (or omitted) preserves the source file's mode. |
[squashfs] — the read-only rootfs image (optional) #
When present, peiso squashes the whole composed root into a read-only image (the live-boot lower layer). Presence of the table is what turns the stage on; out is then required.
| Key | Type | Required | Meaning |
|---|---|---|---|
out | string (spec-relative) | yes (when [squashfs] present) | The image output path. Conventionally written outside root.out so the image can never contain itself. |
exclude | array of strings | no | Source-relative paths to omit from the image. |
compression | string | no | The mksquashfs -comp value (e.g. zstd). Empty uses the mksquashfs default. |
[uki] — the Unified Kernel Image (optional) #
When present, peiso bundles a UKI — kernel + initramfs cpio + kernel cmdline in one EFI binary — using the composed root's own mkuki applet in the chroot. You must supply the cmdline exactly one way: cmdline or cmdline_file, never both and never neither.
| Key | Type | Required | Meaning |
|---|---|---|---|
cmdline | string | conditional | The literal kernel command line. Mutually exclusive with cmdline_file. |
cmdline_file | string (spec-relative) | conditional | A file holding the cmdline, read at build time. Pointing at a file inside the composed root keeps build-time and runtime single-sourced. Mutually exclusive with cmdline. |
out | string (root-relative) | yes (when [uki] present) | The UKI output, relative to root.out — the ESP EFI path (e.g. boot/efi/EFI/BOOT/BOOTX64.EFI). Must not escape the root with ... |
[iso] — the bootable ISO (optional) #
When present, peiso emits a UEFI-bootable ISO9660 image (via xorriso) from an ESP tree. source, efi_boot and out are required; label defaults.
| Key | Type | Required | Meaning |
|---|---|---|---|
source | string (spec-relative) | yes (when [iso] present) | The ESP directory tree packed into the ISO (holding the UKI). |
efi_boot | string (root-relative to source) | yes (when [iso] present) | The EFI boot binary (the UKI) inside the ESP tree, relative to source (e.g. EFI/BOOT/BOOTX64.EFI) — the file firmware boots from the ESP partition. peiso checks it exists before authoring the ISO. Must not escape source with ... |
out | string (spec-relative) | yes (when [iso] present) | The .iso output path. |
label | string | no | The ISO9660 volume label. Defaults to PEIOS. |
[[iso.include]] #
Places a file or a whole directory tree into the ISO9660 data area — the part of the medium that is neither the ESP nor the boot chain. Repeatable.
| Key | Type | Required | Meaning |
|---|---|---|---|
src | string (spec-relative) | yes | Source file or directory on the build host. |
dest | string (ISO-relative) | yes | Where it lands on the medium. Must not escape the ISO root with .., and must not collide with the rootfs squashfs peiso places there itself. |
peiso already puts the rootfs squashfs in the data area; this makes the same space available to the image.
[[]]
= "repo"
= "repo"
Data-area content is not in the squashfs, which is the point. It costs nothing in the root filesystem, is not decompressed at boot, and — because the squashfs is byte-identical to an installed root — is not inherited by machines installed from the medium. An installation medium can carry a package repository without imposing one on every system installed from it.
Files are hard-linked into the staging tree where the filesystems allow it, so including a large tree that already exists in the build directory costs no extra space or time.
[registry] — registry seeds auto-applied on first boot (optional) #
Selects which vendor registry-seed masters (shipped by packages into /usr/share/regim/) this image auto-applies on first boot. peiso stages each into the composed root's autoapply queue; peinit drains it at first boot. Selection lives here, in the image, not in the packages.
| Key | Type | Required | Meaning |
|---|---|---|---|
autoapply | array of strings | no | Bare seed filenames (no path separators), relative to /usr/share/regim/ in the composed root. Each entry is validated to be a bare filename. |
[features] — features enabled on first boot (optional) #
Selects which curated features (shipped by packages into /usr/libexec/features/ and exposed at runtime through /libexec/features/) this image enables on first boot. peiso writes a self-removing autorun script of feat add <name> lines; peinit runs it before Phase 2, so the services a feature creates start the same boot.
| Key | Type | Required | Meaning |
|---|---|---|---|
enable | array of strings | no | Feature names to feat add on first boot. Each must match feat's name grammar (see Validation); the name is embedded verbatim in a generated shell line. |
Validation #
peiso loads, decodes and validates the spec before running anything. The rules, exactly as enforced:
- Unknown keys are rejected. Any key the schema does not define — at any level — fails the build, naming the first offending key.
- Schema gate.
schemamust equal1, or the build fails withunsupported schema. - Required fields.
root.manifest,root.out,initramfs.dirandinitramfs.cpiomust all be present and non-empty. Within an optional table that is present, its own required keys apply:squashfs.out;uki.out;iso.source,iso.efi_bootandiso.out; and bothsrcanddeston everyinjectentry, in[[root.inject]]and[[initramfs.inject]]alike. - No-escape path checks. The root-relative paths
initramfs.dir,initramfs.cpio,uki.out,iso.efi_bootand everyinject.dest(both roots) may not be..or begin with../— they become chroot-absolute paths and must stay inside their tree. (Spec-relative paths are not subject to this check; they resolve to absolute paths.) - UKI cmdline mutual-exclusion. When
[uki]is present, exactly one ofcmdlineorcmdline_filemust be set. Supplying both, or neither, is an error. - Registry seeds must be bare filenames. Each
registry.autoapplyentry must be non-empty and contain no path separator (it must equal its own basename); a path component is a spec error. - Feature-name grammar. Each
features.enableentry must be non-empty, must not be.or.., and must consist only of ASCII alphanumerics plus-,_and.. This mirrors feat's own name grammar and doubles as a guard against shell metacharacters, since the name is embedded in a generatedfeat addline. - Inject mode.
inject.mode, when set, must parse as an octal integer; a malformed value fails the build. Applies to both inject tables.
A spec that passes all of these checks is what the build pipeline runs.
See also #
- Building a Peios image — what peiso is and where it sits above compose.
- The build pipeline — the stage each table drives, in run order.
- Running a build — the command, exit codes, and required tools.