# Peios

> The operating system itself — identity, tokens, security descriptors, processes, and the kernel subsystems behind them.

---

# Identity in Peios

_Peios / Peios Security Fundamentals / Identity_

> How Peios identifies who is acting — principals named by SIDs, carried by tokens, anchored in logon sessions, and checked at every access decision.

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

```mermaid
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:

1. 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.
2. **authd**, the authentication authority, creates a **logon session**: a kernel object that records the authentication event (who, how, when).
3. 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](/peios/security-fundamentals/impersonation/overview.md).

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](/peios/advanced-peios/linux-compatibility/overview.md) 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](/peios/security-fundamentals/tokens/overview.md) and [Logon sessions](/peios/security-fundamentals/logon-sessions/overview.md).
- **Authorisation says "what".** [Security descriptors](/peios/security-fundamentals/security-descriptors/overview.md) and [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

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](/peios/security-fundamentals/privileges/overview.md).
- **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](/peios/security-fundamentals/process-integrity-protection/overview.md).

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](/peios/security-fundamentals/identity/sids.md). For the catalog of built-in principals and their fixed SIDs, read [Well-known principals](/peios/security-fundamentals/identity/well-known-principals.md).

If you want the typed attributes a token carries beyond identity — the inputs to attribute-based access decisions — read [Claims on a token](/peios/security-fundamentals/identity/claims.md).

If you want the runtime mechanics — how a token is composed, how it survives fork and exec, and how it gets adjusted — read [Tokens](/peios/security-fundamentals/tokens/overview.md).

If you are debugging an unexpected denial, the [Inspecting tokens, sessions, and processes](/peios/security-fundamentals/inspecting/overview.md) 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 — the string form you see in logs, the binary form on the wire, and the comparison rule.

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](/peios/using-peios/wire-formats-reference/overview.md), 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\Administrators` for `S-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](/peios/security-fundamentals/identity/well-known-principals.md). 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_level` field).
- 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](/peios/security-fundamentals/identity/well-known-principals.md).

For the typed attributes that travel alongside SIDs on a token, read [Claims on a token](/peios/security-fundamentals/identity/claims.md).

For how a SID is carried into every access decision at runtime, read [Tokens](/peios/security-fundamentals/tokens/overview.md).

---

# Well-known principals

_Peios / Peios Security Fundamentals / Identity_

> The catalog of principals whose SIDs are fixed by the system — what each well-known SID means, how it behaves in an access check, and which ones to use.

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](/peios/security-fundamentals/security-descriptors/ownership.md). |

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](/peios/security-fundamentals/access-decisions/mandatory-integrity-control.md) 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](/peios/security-fundamentals/process-integrity-protection/overview.md).

| 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](/peios/security-fundamentals/confinement/overview.md) 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\Administrators`** and **`SYSTEM`** for default protective DACLs.
- **`Authenticated Users`** for "any logged-in user should be able to read this".
- **`Everyone`** only when you actually mean "including unauthenticated network peers".
- **`Creator Owner`** in inheritable ACEs that should follow the owner of a child object.
- **`Owner Rights`** when 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-...-RID` form) 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](/peios/security-fundamentals/identity/claims.md).

For the exact numeric values in machine-readable form, see the [Well-known SIDs](/peios/using-peios/constants-and-catalogs/well-known-sids.md) reference.

---

# Claims on a token

_Peios / Peios Security Fundamentals / Identity_

> Claims are typed key-value attributes on a token — user and device attributes from the directory that conditional ACEs evaluate during access checks.

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 `Department` or `Clearance`. 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 `STRING`s, 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

```mermaid
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:

- **`@Resource`** values come from resource attributes in the object's SACL, not the caller's token. See [Resource attributes](/peios/security-fundamentals/security-descriptors/resource-attributes.md).
- **`@Local`** values 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](/peios/security-fundamentals/security-descriptors/conditional-aces.md).

## 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](/peios/security-fundamentals/tokens/overview.md).

For the expression grammar that consumes claims — operators, three-valued logic, worked examples — read [Conditional ACEs](/peios/security-fundamentals/security-descriptors/conditional-aces.md).

---

# Tokens

_Peios / Peios Security Fundamentals / Tokens_

> A token is the kernel object that carries an identity into every system call — what it contains, where it comes from, and how it lives and dies.

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](/peios/security-fundamentals/identity/overview.md) 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](/peios/security-fundamentals/tokens/token-types.md); 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](/peios/security-fundamentals/tokens/token-types.md) for the exact rules.

## Tokens and threads

```mermaid
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](/peios/security-fundamentals/impersonation/overview.md). 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:

1. Authentication succeeds, or a service is being launched.
2. 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.
3. It calls `kacs_create_token` with the resolved values.
4. The kernel returns a token file descriptor with `TOKEN_ALL_ACCESS`.
5. 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](/peios/security-fundamentals/tokens/token-types.md).

If you want to know how a token moves through fork, exec, adjustment, and destruction, read [Token lifecycle](/peios/security-fundamentals/tokens/lifecycle.md).

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](/peios/security-fundamentals/tokens/restricted-tokens.md).

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](/peios/security-fundamentals/tokens/elevation.md).

To work with tokens from a shell — inspect, adjust, duplicate, restrict, impersonate — read [The token command](/peios/security-fundamentals/tokens/token-command.md).

---

# Token types and fields

_Peios / Peios Security Fundamentals / Tokens_

> The axes a token is classified along — primary or impersonation, impersonation level, restricted, elevation type — and the token's fields grouped by purpose.

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](/peios/security-fundamentals/impersonation/overview.md) 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](/peios/security-fundamentals/tokens/restricted-tokens.md). 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](/peios/security-fundamentals/tokens/elevation.md).

## 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](/peios/using-peios/kernel-abi-reference/overview.md) 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](/peios/security-fundamentals/access-decisions/mandatory-integrity-control.md)). |
| `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](/peios/security-fundamentals/privileges/overview.md) 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](/peios/security-fundamentals/confinement/overview.md) 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](/peios/security-fundamentals/identity/claims.md).

### 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](/peios/advanced-peios/linux-compatibility/overview.md) 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](/peios/security-fundamentals/tokens/lifecycle.md).

For the full rules around impersonation tokens and their levels, read [Impersonation](/peios/security-fundamentals/impersonation/overview.md).

To read these fields off a live token from a shell, read [The token command](/peios/security-fundamentals/tokens/token-command.md).

---

# Token lifecycle

_Peios / Peios Security Fundamentals / Tokens_

> A token's life from minting by authd or peinit to destruction at the last reference — fork, exec, impersonation, adjustment, and the default DACL.

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:

1. Validates the inputs (well-formed SIDs, no duplicate luids, all index references in range, etc.).
2. Allocates a new token object with `refcount = 1`.
3. Assigns a fresh `token_id` LUID and `modified_id = 0`.
4. Stamps the `created_at` timestamp.
5. Injects the appropriate logon SID into the groups list (callers must not supply it).
6. 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_INSTALL` on a token fd makes the token the calling process's primary. Requires `TOKEN_ASSIGN_PRIMARY` on the token and `SeAssignPrimaryTokenPrivilege` on 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_IMPERSONATE` on a token fd makes the token the calling thread's impersonation token. Requires `TOKEN_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

```mermaid
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_IMPERSONATE`** on 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](/peios/security-fundamentals/impersonation/overview.md).

## 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_open` with `CREATE` disposition, 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](/peios/security-fundamentals/logon-sessions/overview.md).

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](/peios/security-fundamentals/inspecting/overview.md).

## 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](/peios/security-fundamentals/tokens/restricted-tokens.md).

For the session a token belongs to and what happens when the last token of a session is released, read [Logon sessions](/peios/security-fundamentals/logon-sessions/overview.md).

To drive these transitions from a shell — duplicate, restrict, adjust, install — read [The token command](/peios/security-fundamentals/tokens/token-command.md).

---

# Restricted and write-restricted tokens

_Peios / Peios Security Fundamentals / Tokens_

> A restricted token carries a secondary SID list; AccessCheck runs twice and intersects the results. Write-restricted applies the same idea to write rights only.

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

```mermaid
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:

1. **Normal pass.** The DACL walk uses the token's `user_sid`, all enabled groups, and all the usual rules. Produces a `granted_normal` mask.
2. **Restricted pass.** The DACL walk runs again, but the only SIDs considered for matching are those in `restricted_sids`. The `user_sid` does not match. None of the regular `groups` match. Only the entries in `restricted_sids` count. Produces a `granted_restricted` mask.
3. **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_ONLY` in 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:

1. Open its own primary token.
2. Call `KACS_IOC_RESTRICT` to produce a restricted variant: remove every privilege except `SeChangeNotifyPrivilege`, mark unsafe groups deny-only, add the sandbox's allowed capability SIDs as the restricted SIDs, set the write-restricted flag.
3. Fork.
4. `KACS_IOC_INSTALL` the restricted token on the child.
5. 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](/peios/security-fundamentals/confinement/overview.md)). 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_RESTRICT` with 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_sids` containing 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](/peios/security-fundamentals/tokens/elevation.md).

For the policy-driven counterpart to restricted tokens, read [Confinement](/peios/security-fundamentals/confinement/overview.md).

To build a restricted token from a shell, read [The token command](/peios/security-fundamentals/tokens/token-command.md).

---

# Elevation and linked tokens

_Peios / Peios Security Fundamentals / Tokens_

> Some principals carry two linked tokens — a Limited default and a Full elevated half — paired on the logon session and switched on request.

> [!IMPORTANT]
> **Not yet built in userspace.** The kernel side of this page is complete and works as described — `KACS_IOC_LINK_TOKENS`, `KACS_IOC_GET_LINKED_TOKEN`, and the `elevation_type` field are all implemented. **What does not happen yet is anything creating a pair.** `authd` mints a single token carrying everything the principal is entitled to, and `login` runs the session on it; no interactive logon produces a Limited half, and there is no broker holding a Full one.
>
> So on a current machine an administrator's shell holds their administrative privileges and High integrity for its whole life, and there is no elevation boundary. Read what follows as the model the system is built towards and the kernel already supports, not as a description of how your machine behaves today.
>
> The blocker is not the token work. A Limited token marks `BUILTIN\Administrators` as `USE_FOR_DENY_ONLY`, and the security descriptor a stock image ships grants only `LocalSystem` and `BUILTIN\Administrators` — so a Limited shell could not list a directory. Per-subtree descriptors have to come first, otherwise the boundary would land on a machine nobody could use.

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

```mermaid
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:

- `SeTcbPrivilege` on the caller.
- `TOKEN_DUPLICATE` on 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:

1. Records the pair on the session.
2. Sets the elevated token's `elevation_type = Full`.
3. 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:

1. authd authenticates the user.
2. authd creates the user's session.
3. authd mints the Full token with all the user's entitled privileges and groups.
4. authd FilterTokens the Full token down to the Limited version — privileges removed, admin groups deny-only.
5. authd calls `KACS_IOC_LINK_TOKENS` to link the two.
6. authd installs the Limited token as the primary of the user's first process.
7. 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 with `TOKEN_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_TOKEN` sees 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_TOKEN` on either token return `-ENOENT`.
- The individual tokens themselves continue to exist as long as their reference counts hold. Their `elevation_type` values are unchanged but no longer meaningful.

A session is destroyed when its last token reference drops (see [Logon sessions](/peios/security-fundamentals/logon-sessions/overview.md)). 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](/peios/security-fundamentals/logon-sessions/overview.md).

To inspect a token's elevation type and its partner from a shell, read [The token command](/peios/security-fundamentals/tokens/token-command.md).

---

# The token command

_Peios / Peios Security Fundamentals / Tokens_

> The token command inspects and manipulates tokens — reading a token's contents, adjusting it, duplicating and restricting it, and driving impersonation.

`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](/peios/security-fundamentals/impersonation/peer-tokens.md). |

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](/peios/security-fundamentals/tokens/restricted-tokens.md) — 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](/peios/security-fundamentals/tokens/elevation.md).

## 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](/peios/security-fundamentals/impersonation/overview.md) 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](/peios/security-fundamentals/inspecting/tokens.md) — 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 recording one authentication event — every token belongs to exactly one, tied together by the session's logon SID.

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](/peios/security-fundamentals/logon-sessions/logon-types.md). |
| `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.

```mermaid
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](/peios/security-fundamentals/logon-sessions/logon-types.md).

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](/peios/security-fundamentals/logon-sessions/lifecycle.md).

If you want to see which sessions are currently active on a running system, read [Inspecting tokens, sessions, and processes](/peios/security-fundamentals/inspecting/overview.md).

To work with sessions from a shell — list them, see their processes, create and destroy them — read [The logonse command](/peios/security-fundamentals/logon-sessions/logonse-command.md).

---

# Logon types

_Peios / Peios Security Fundamentals / Logon Sessions_

> Every logon session is tagged with a logon type — Interactive, Network, Batch, Service, NetworkCleartext, or NewCredentials — and what each type means.

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](/peios/using-peios/constants-and-catalogs/other-constants.md).

| 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/sessions` and the session inspection APIs.
- **In every token's session** — via the token's `auth_id` field, 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](/peios/security-fundamentals/logon-sessions/lifecycle.md).

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](/peios/security-fundamentals/identity/well-known-principals.md).

To see the logon type of each active session from a shell, read [The logonse command](/peios/security-fundamentals/logon-sessions/logonse-command.md).

---

# Session lifecycle

_Peios / Peios Security Fundamentals / Logon Sessions_

> A logon session is created by authd at successful authentication and destroyed when its last token reference drops — there is no kernel revocation primitive.

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](/peios/security-fundamentals/logon-sessions/logon-types.md). |
| `auth_package` | A string identifying the authentication mechanism (informational). |
| `user_sid` | The principal who signed in. |

The kernel:

1. Validates the inputs (well-formed SID, recognised logon type, sane string lengths).
2. Allocates a session object with a fresh `session_id` LUID.
3. Stamps `created_at`.
4. Derives the logon SID `S-1-5-5-X-Y` from the session ID.
5. Initialises the session's token reference count at zero.
6. 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:

1. Removes the session from the session table.
2. Tears down any linked-pair association it was holding (see [Elevation and linked tokens](/peios/security-fundamentals/tokens/elevation.md)).
3. Emits a `logon-session-destroyed` event 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:

1. Every process running on a token of the session has exited.
2. Every thread that was impersonating a token of the session has reverted or exited.
3. Every fd held on a token of the session has been closed.
4. 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:

1. Walk `/proc/*/token` to find tokens whose `auth_id` matches the target session.
2. For each matching token, identify the process holding it.
3. Send the appropriate signal to terminate the process (typically SIGTERM with a grace period, then SIGKILL if needed).
4. 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](/peios/security-fundamentals/inspecting/overview.md).

## 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](/peios/security-fundamentals/tokens/lifecycle.md).

To list, create, and destroy sessions from a shell, read [The logonse command](/peios/security-fundamentals/logon-sessions/logonse-command.md).

---

# The logonse command

_Peios / Peios Security Fundamentals / Logon Sessions_

> The logonse command lists logon sessions and their processes, creates and destroys them, and sets a process's mitigation flags.

`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](/peios/security-fundamentals/inspecting/sessions.md) — 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.

> [!NOTE]
> The tool builds the kernel's binary session spec from these fields for you. The underlying [wire format](/peios/using-peios/wire-formats-reference/token-and-session-specs.md) is unchanged; only the command-line surface is typed.

### `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](/peios/security-fundamentals/logon-sessions/lifecycle.md).

## 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](/peios/security-fundamentals/process-mitigations/overview.md).

## 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_

> How a sign-in works on Peios — the conversation with the authority on /run/logon.sock, the roles involved, and why authentication and derivation are separate.

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](/peios/security-fundamentals/managing-local-principals/overview.md).

## 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](/peios/security-fundamentals/managing-local-principals/creating-accounts.md) 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 `exec`s 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](/peios/security-fundamentals/privileges/overview.md) 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](/peios/security-fundamentals/logon-sessions/logon-types.md).

**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](/peios/security-fundamentals/managing-local-principals/resolving-names.md).

## Where to start

- [The `login` command](/peios/security-fundamentals/signing-in/the-login-command.md) — the terminal client, and how a live image signs in without being asked anything.
- [Logon sessions](/peios/security-fundamentals/logon-sessions/overview.md) — the kernel object a successful sign-in creates.
- [Managing local principals](/peios/security-fundamentals/managing-local-principals/overview.md) — the accounts being signed in to.

The protocol is specified rather than merely documented: [PGSS §2](/peios/advanced-peios/pgss/logon/scope-and-roles.md) is Logon, and [PSPU §2](/peios/advanced-peios/pspu/principal-source-interface/scope-and-roles.md) 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 PGSS Logon — it collects an identifier and whatever credentials the authority asks for, and starts your shell.

`login` is the terminal client for [signing in](/peios/security-fundamentals/signing-in/overview.md). 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](/peios/security-fundamentals/managing-local-principals/creating-accounts.md).

## `--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`](/peios/security-fundamentals/managing-local-principals/lps-command.md). To move the prompt to another terminal, change the service's `TTYPath` — see [controlling services](/peios/using-peios/services-and-jobs/controlling-services.md).

## 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_

> Where the accounts on a Peios machine live — the authority that mints tokens, the sources that hold identity, and lpsd, the one that owns the local store.

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](/peios/security-fundamentals/privileges/assigning-privileges.md).

## 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](/peios/security-fundamentals/managing-local-principals/resolving-names.md).

## Where to start

- [The local store](/peios/security-fundamentals/managing-local-principals/the-local-store.md) — what `lpsd` keeps, and where.
- [Resolving names](/peios/security-fundamentals/managing-local-principals/resolving-names.md) — how a name, a SID or a number becomes a principal.
- [Creating accounts](/peios/security-fundamentals/managing-local-principals/creating-accounts.md) — the first one, and every one after.
- [The `lps` command](/peios/security-fundamentals/managing-local-principals/lps-command.md) — the full reference.

The protocols themselves are specified rather than merely documented: [PGSS §2](/peios/advanced-peios/pgss/logon/scope-and-roles.md) is Logon, and [PSPU §2](/peios/advanced-peios/pspu/principal-source-interface/scope-and-roles.md) 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_

> What lpsd keeps on disk — the machine's domain SID, its principals, and the argon2id verifiers that stand in for their passwords — and why it is one file.

`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](/peios/security-fundamentals/managing-local-principals/resolving-names.md).

> [!NOTE]
> A `-` where a uid should be means `authd` has assigned this machine no range at all, and every principal here will sign in as `nobody`. The `UnixIDBase` value on this source is missing or unusable.

## 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](/peios/security-fundamentals/privileges/assigning-privileges.md).

## 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_

> How the first account on a machine comes to exist, how to create the ones after it, and what happens on an image that ships a development account.

## 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](/peios/security-fundamentals/managing-local-principals/lps-command.md) 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](/peios/security-fundamentals/managing-local-principals/lps-command.md).

## 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](/peios/security-fundamentals/managing-local-principals/lps-command.md).

For what the store holds and why RIDs are never reused, read [The local store](/peios/security-fundamentals/managing-local-principals/the-local-store.md).

---

# The lps command

_Peios / Peios Security Fundamentals / Managing Local Principals_

> The lps command administers the local principal store — principals, passwords, groups and memberships, profiles, and claims.

`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](/peios/security-fundamentals/managing-local-principals/the-local-store.md).

### `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](/peios/security-fundamentals/managing-local-principals/creating-accounts.md).

## 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](/peios/security-fundamentals/managing-local-principals/the-admin-socket.md).

**`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_

> The socket lps talks to lpsd over — where it is, who may use it, and what decides that. Useful if you are writing tooling against it or diagnosing a refusal.

`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.

> [!TIP]
> If you see `permission denied` from `lps`, it is a KACS refusal, not a Unix one. Check the descriptor on `/run/lpsd/admin.sock` with `sd show`, and check your token's groups with `token`.

## Shape of the protocol

One request per connection: `lps` connects, sends one request, reads one answer, and disconnects. There is no session and no state carried between commands.

That is partly the shape of the tool — a command does one thing and exits — and partly a bound on a real hazard. `lpsd` serves administrative requests on the same thread as logons, so a client that connected and then said nothing would stall every logon behind it. One request under a deadline bounds that.

The authorization check happens **before** anything is read, so an unauthorised connection costs a token read and a refusal rather than the full timeout. An open socket cannot be used to stall logons.

## Durability

A change is applied in memory, **written to disk, and only then reported as done**. If the write fails the change is rolled back and the command reports failure.

So a 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](/peios/advanced-peios/pgss/logon/scope-and-roles.md)) and PSI ([PSPU §2](/peios/advanced-peios/pspu/principal-source-interface/scope-and-roles.md)). 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 `lps` command](/peios/security-fundamentals/managing-local-principals/lps-command.md) — the client this socket serves.
- [Managing local principals](/peios/security-fundamentals/managing-local-principals/overview.md) — the authority/source split behind the design.

---

# Resolving names

_Peios / Peios Security Fundamentals / Managing Local Principals_

> How a name, a SID or a POSIX identifier becomes a principal — why only the authority can answer, the search order, and why "unavailable" is not "not found".

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](/peios/advanced-peios/pgss/logon/scope-and-roles.md) 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`](/peios/advanced-peios/linux-compatibility/name-service-switch.md), 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.

> [!NOTE]
> A machine with one source has nothing to order, and can ignore this entirely.

## 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.

> [!TIP]
> If names stop resolving and start reporting temporary failures, look for a principal source that is not running. `authd` logs which one it was waiting for.

## 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](/peios/advanced-peios/linux-compatibility/name-service-switch.md).

For where the local principals being resolved actually live, read [The local store](/peios/security-fundamentals/managing-local-principals/the-local-store.md).

---

# Impersonation

_Peios / Peios Security Fundamentals / Impersonation_

> How a thread temporarily acts as another principal — the two-token model, the canonical server flow, and just-in-time impersonation for modern runtimes.

**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:

```mermaid
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](/peios/security-fundamentals/process-integrity-protection/overview.md) for the why.

## A canonical server flow

The simplest case — one OS thread, one request, start to finish — follows a flow like this:

```mermaid
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"]
```

1. **Client connects.** Before connect, the client may have called `kacs_set_impersonation_level` on the socket to bound how far its identity may travel (see [Impersonation levels](/peios/security-fundamentals/impersonation/impersonation-levels.md)).
2. **Server accepts.** The kernel captures the client's identity onto the socket at connect time.
3. **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.
4. **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.
5. **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.

```mermaid
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:

1. 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.
2. Most of the request is processed as the service's own identity. Decoding, dispatching, internal bookkeeping, response framing — none of these need the client.
3. 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 calls `kacs_revert` immediately.
4. The request continues at service identity. Further per-request impersonations follow the same install-do-revert pattern.
5. 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](/peios/security-fundamentals/impersonation/the-two-gates.md)), 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](/peios/security-fundamentals/impersonation/impersonation-levels.md).

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](/peios/security-fundamentals/impersonation/the-two-gates.md).

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](/peios/security-fundamentals/impersonation/peer-tokens.md).

---

# Impersonation levels

_Peios / Peios Security Fundamentals / Impersonation_

> The four impersonation levels — Anonymous, Identification, Impersonation, Delegation — what each permits, and the pitfalls each one produces.

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](/peios/using-peios/constants-and-catalogs/other-constants.md).

| 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](/peios/security-fundamentals/tokens/elevation.md).

## 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](/peios/security-fundamentals/impersonation/peer-tokens.md)).
- 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:

1. **The client's request**, set on the socket before connect via `kacs_set_impersonation_level`. Default is Impersonation if the client says nothing.
2. **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](/peios/security-fundamentals/impersonation/the-two-gates.md).
3. **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_peer` and must use the explicit-fd ioctl instead. See [Peer tokens and capture](/peios/security-fundamentals/impersonation/peer-tokens.md).

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](/peios/security-fundamentals/impersonation/the-two-gates.md).

For how a server gets hold of a client's identity in the first place, read [Peer tokens and capture](/peios/security-fundamentals/impersonation/peer-tokens.md).

---

# The two-gate model

_Peios / Peios Security Fundamentals / Impersonation_

> The two gates that decide a server's effective impersonation level — the identity gate and the integrity ceiling — and the silent downgrade they produce.

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:

1. The **identity gate** — is the server allowed to impersonate this particular user's identity?
2. 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:

1. Call `kacs_impersonate_peer(fd)` (or the explicit-fd variant).
2. Open the thread's effective token.
3. Read `impersonation_level` via `KACS_IOC_QUERY`.
4. 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_revert` always succeeds and does not consult the gates.
- **Reading a token.** `kacs_open_peer_token` or `kacs_open_thread_token` returns 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](/peios/security-fundamentals/process-integrity-protection/overview.md)) 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](/peios/security-fundamentals/impersonation/peer-tokens.md).

For the restricted tokens behind the one hard-deny case, read [Restricted and write-restricted tokens](/peios/security-fundamentals/tokens/restricted-tokens.md).

---

# Peer tokens and capture

_Peios / Peios Security Fundamentals / Impersonation_

> How the kernel captures a client's identity onto a socket at connect, the two syscalls that use it, which transports carry peer tokens, and identity cascading.

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

```mermaid
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:

1. Reads the client's effective token (impersonation if set, primary otherwise).
2. Reads the impersonation level the client requested on the socket (default Impersonation).
3. Constructs a peer token derived from the client's token, at the requested level.
4. 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 carries `TOKEN_QUERY | TOKEN_IMPERSONATE` access — enough to read the token and to install it later via `KACS_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_peer` and then `kacs_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](/peios/security-fundamentals/impersonation/overview.md) covered on the overview page — calls `kacs_open_peer_token` to 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_token` mechanism 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 via `KACS_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_RIGHTS` message; 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.

```mermaid
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):

1. **Accept the connection.** Standard `accept()` on the Unix socket. The kernel has already captured the client's peer token onto this socket.
2. **Impersonate the peer.** Call `kacs_impersonate_peer(fd)`. The kernel runs the two-gate model and installs the resulting token.
3. **(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.
4. **Do the work.** Every access check on this thread is now against the impersonation token.
5. **Revert.** Call `kacs_revert()`. The thread is back to its primary identity.
6. **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):

1. **Accept the connection.** As above.
2. **Capture the peer token fd.** Call `kacs_open_peer_token(fd)` and store the returned token fd in the request context. The fd carries `TOKEN_QUERY | TOKEN_IMPERSONATE`.
3. **(Optional) Inspect the captured token.** Query the captured fd to verify the identity and level before doing any work.
4. **Do most of the request as the service.** Decoding, dispatch, internal bookkeeping, response framing — all run as the service's own identity.
5. **For each access-requiring action**, install the captured token on the current OS thread (`KACS_IOC_IMPERSONATE` on the stored fd), do the single action, call `kacs_revert()` immediately. Keep the impersonation window as tight as possible.
6. **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_revert` continues to impersonate the previous client. The next request handled on that thread uses the wrong identity. Make `kacs_revert` part 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](/peios/security-fundamentals/security-descriptors/overview.md).

To drive impersonation from a shell — impersonate a peer, revert, inspect the effective token — read [The token command](/peios/security-fundamentals/tokens/token-command.md).

---

# Security descriptors

_Peios / Peios Security Fundamentals / Security Descriptors_

> Every protected object carries one security descriptor — the owner, primary group, DACL, and SACL that define who may do what to it and what is audited.

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](/peios/security-fundamentals/tokens/overview.md) 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](/peios/security-fundamentals/security-descriptors/ownership.md)). |
| **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`](/peios/security-fundamentals/privileges/overview.md)). |

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](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md) 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](/peios/security-fundamentals/security-descriptors/acls-and-aces.md) 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](/peios/security-fundamentals/security-descriptors/inheritance.md) 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](/peios/security-fundamentals/security-descriptors/ownership.md)). |
| 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:

1. **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](/peios/security-fundamentals/security-descriptors/inheritance.md).
2. **Explicitly supplied at creation.** The creator passes an SD as a parameter (to `kacs_open` with 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).
3. **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_ephemeral` policy 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](/peios/security-fundamentals/access-decisions/overview.md); a brief sketch for orientation:

1. The owner field is consulted to determine implicit rights (and whether they have been suppressed by OWNER RIGHTS).
2. The SACL is scanned for mandatory integrity labels and PIP trust labels — these gate the access independently of the DACL.
3. The DACL is walked, ACE by ACE, in order, applying first-writer-wins.
4. 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](/peios/security-fundamentals/security-descriptors/acls-and-aces.md).

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](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md).

If you want to know how owner implicit rights work and why OWNER RIGHTS can suppress them, read [Ownership and implicit rights](/peios/security-fundamentals/security-descriptors/ownership.md).

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](/peios/security-fundamentals/security-descriptors/inheritance.md).

If you want conditional ACEs — the ABAC-style mechanism where access depends on token claims and resource attributes — read [Conditional ACEs](/peios/security-fundamentals/security-descriptors/conditional-aces.md).

If you want the SACL specifically — audit, alarm, integrity labels, central access policy references, PIP trust labels — read [The SACL](/peios/security-fundamentals/security-descriptors/the-sacl.md).

To read and change a descriptor from a shell — owner, DACL, SACL, label, inheritance — read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# ACLs, ACEs, and access masks

_Peios / Peios Security Fundamentals / Security Descriptors_

> The shared structure of DACLs and SACLs — ACL and ACE layout, the catalog of ACE types, the inheritance and audit flags, and the 32-bit access mask.

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](/peios/using-peios/wire-formats-reference/security-descriptors.md).

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](/peios/security-fundamentals/security-descriptors/conditional-aces.md).

The detailed byte-level layouts live in the [Wire formats reference](/peios/using-peios/wire-formats-reference/overview.md). 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](/peios/using-peios/constants-and-catalogs/ace-types-and-flags.md); 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](/peios/security-fundamentals/security-descriptors/the-sacl.md) and [Auditing](/peios/security-fundamentals/auditing/overview.md) 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](/peios/security-fundamentals/access-decisions/overview.md), resource attributes under [Resource attributes](/peios/security-fundamentals/security-descriptors/resource-attributes.md), CAAP under [Central access policies](/peios/security-fundamentals/central-access-policies/overview.md), PIP under [Process integrity protection](/peios/security-fundamentals/process-integrity-protection/overview.md)).

## 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](/peios/using-peios/constants-and-catalogs/ace-types-and-flags.md).)

| 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](/peios/security-fundamentals/security-descriptors/inheritance.md).

## 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](/peios/using-peios/constants-and-catalogs/access-mask-bits.md); the mask's byte layout is in the [SD wire format](/peios/using-peios/wire-formats-reference/security-descriptors.md). A few things worth noting about the layout:

- **The same bit means different things on different object types.** Bit 0 is `FILE_READ_DATA` on a file but `KEY_QUERY_VALUE` on 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.** `DELETE` always means delete the object; `READ_CONTROL` always means read the SD; `WRITE_DAC` always means modify the DACL; `WRITE_OWNER` always means change the owner. These bits work identically regardless of object type.
- **Generic rights are abstract.** `GENERIC_READ` does 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](/peios/using-peios/constants-and-catalogs/overview.md) 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](/peios/using-peios/constants-and-catalogs/other-constants.md). 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](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md).

For the full numeric catalog of ACE types, flags, and access-mask bits, see [ACE types and flags](/peios/using-peios/constants-and-catalogs/ace-types-and-flags.md).

To read and edit ACLs from a shell, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# DACL evaluation

_Peios / Peios Security Fundamentals / Security Descriptors_

> How a DACL decides — the first-writer-wins walk, canonical ACE order, the NULL-vs-empty DACL distinction, and what MAXIMUM_ALLOWED changes.

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:

1. `ACCESS_ALLOWED Alice FILE_READ_DATA | FILE_WRITE_DATA`
2. `ACCESS_DENIED Alice FILE_WRITE_DATA`

Alice asks for `FILE_READ_DATA | FILE_WRITE_DATA`. The walk:

- ACE 1 matches Alice. `FILE_READ_DATA` and `FILE_WRITE_DATA` are not yet decided. Both are added to `decided` and to `granted`.
- ACE 2 matches Alice. `FILE_WRITE_DATA` is 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:

1. `ACCESS_DENIED Alice FILE_WRITE_DATA`
2. `ACCESS_ALLOWED Alice FILE_READ_DATA | FILE_WRITE_DATA`

The walk:

- ACE 1 matches Alice. `FILE_WRITE_DATA` is not yet decided. It is added to `decided` but not to `granted` (denied).
- ACE 2 matches Alice. `FILE_READ_DATA` is not yet decided — it is added to both. `FILE_WRITE_DATA` is already decided. The ACE only takes effect on `FILE_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](/peios/security-fundamentals/security-descriptors/ownership.md)). The walk happens, decides nothing, and the result is an empty `granted` mask. |

> [!WARNING]
> The NULL case is rare and dangerous. It is how you say "this object has no discretionary access control at all — anyone can do anything". You will see it on a few system objects where no DACL would be the right thing. You will never see it on a file you set permissions on.

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`/`granted` arithmetic 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](/peios/security-fundamentals/security-descriptors/ownership.md).
- **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](/peios/security-fundamentals/access-decisions/overview.md).

## 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](/peios/security-fundamentals/security-descriptors/ownership.md).

For the full pipeline the walk sits inside — MIC, PIP, privileges, narrowing layers — read [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

To test what a DACL would grant from a shell, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# Ownership and implicit rights

_Peios / Peios Security Fundamentals / Security Descriptors_

> The owner's implicit READ_CONTROL and WRITE_DAC — the you-cannot-lock-yourself-out guarantee — how OWNER RIGHTS suppresses them, and changing ownership.

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-4` as its SID, and
- The ACE has `INHERIT_ONLY_ACE` clear (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."

> [!WARNING]
> These constructions are not always wise. Locking the owner out of `WRITE_DAC` means no one short of an administrator with `SeTakeOwnership` can change the DACL. That can be exactly what you want, but it is also how an object becomes administratively unrecoverable. Use sparingly.

## 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 `groups` list with the `SE_GROUP_OWNER` flag 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_GROUP` to 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_DAC` lets 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 `SeTakeOwnership` can 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 `SeRestorePrivilege` can 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 RIGHTS` ACE — explicitly grant the owner what you want them to have (perhaps nothing) and the implicit grant of `READ_CONTROL | WRITE_DAC` will be suppressed. Use with care; this is what makes objects administratively unrecoverable except via `SeTakeOwnership`.

## Where to go next

For how the `CREATOR_OWNER` and `CREATOR_GROUP` placeholders get substituted when children are created, read [Inheritance](/peios/security-fundamentals/security-descriptors/inheritance.md).

For how `SeTakeOwnership` and `SeRestore` fit the wider privilege model, read [Privileges](/peios/security-fundamentals/privileges/overview.md).

To view and change an object's owner from a shell, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# Inheritance

_Peios / Peios Security Fundamentals / Security Descriptors_

> A child's SD is computed once, at creation, from the parent's inheritable ACEs and the creator's defaults — the parent is never walked at access-check time.

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](/peios/security-fundamentals/security-descriptors/acls-and-aces.md) 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:

1. The **parent's** SD. Inheritable ACEs from here will be copied (with adjustments) into the child.
2. 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.
3. The **creator's token**. Provides defaults (owner, primary group, default DACL) when the creator did not supply specific values.

The merge proceeds as follows:

1. **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](/peios/security-fundamentals/security-descriptors/ownership.md)). Otherwise, use the creator's token's default owner.
2. **Compute the child's primary group.** Same rule: explicit SD if present, otherwise the creator's token's default primary group.
3. **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).
4. **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_ACE` flag (0x10) is **set** in the copy. This marks it as having come from inheritance, not from explicit assignment.
- If `NO_PROPAGATE_INHERIT_ACE` was set, the `OI` and `CI` flags 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](/peios/security-fundamentals/security-descriptors/conditional-aces.md).

To sweep a tree and propagate inheritable ACEs from a shell, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# Conditional ACEs

_Peios / Peios Security Fundamentals / Security Descriptors_

> An ACE gated by an expression over token claims, resource attributes, and local context — the model, the expression language, and the three-valued logic.

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](/peios/security-fundamentals/security-descriptors/acls-and-aces.md):

| 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](/peios/security-fundamentals/identity/claims.md). Resource attributes are covered on [Resource attributes](/peios/security-fundamentals/security-descriptors/resource-attributes.md). Local claims are passed by the caller; the API surface lives in the [Kernel ABI reference](/peios/using-peios/kernel-abi-reference/overview.md).

## 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`](/peios/security-fundamentals/security-descriptors/sd-command.md) 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](/peios/using-peios/wire-formats-reference/overview.md).

### 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 `INT64` and `UINT64` with sign-aware promotion: a negative `INT64` is always less than any `UINT64`.
- **String comparisons** are case-insensitive by default. A claim or attribute can opt into case sensitivity via the `CLAIM_SECURITY_ATTRIBUTE_VALUE_CASE_SENSITIVE` flag (see [Claims on a token](/peios/security-fundamentals/identity/claims.md)).
- **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:

1. `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 group
2. `ACCESS_ALLOWED Authenticated_Users GENERIC_READ` — allow read to authenticated users
3. `ACCESS_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_DATA` and friends are decided and not granted.
- ACE 2: also matches Alice, but `FILE_READ_DATA` etc. 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_DATA` not yet decided. Granted.
- ACE 3: matches Bob. `FILE_WRITE_DATA` not 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 if `Not_Member_of` is TRUE, UNKNOWN AND TRUE is UNKNOWN. The deny ACE has UNKNOWN, which **applies** (denies err on the side of denying). `FILE_READ_DATA` is 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](/peios/security-fundamentals/security-descriptors/resource-attributes.md).

For the token-side attributes behind `@User.*` and `@Device.*`, read [Claims on a token](/peios/security-fundamentals/identity/claims.md).

---

# Resource attributes

_Peios / Peios Security Fundamentals / Security Descriptors_

> A typed key-value attribute attached to an object through its SACL — referenced by conditional ACEs as @Resource.<name>, never granting anything by itself.

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](/peios/security-fundamentals/identity/claims.md), 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](/peios/using-peios/wire-formats-reference/overview.md).

Two structural notes:

- **The first non-inherit-only ACE for each name wins.** If two `SYSTEM_RESOURCE_ATTRIBUTE_ACE` ACEs 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:

1. Scan the object's SACL for a `SYSTEM_RESOURCE_ATTRIBUTE_ACE` whose attribute name matches `<name>`.
2. If found, read the value(s) and use them in the expression.
3. 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](/peios/security-fundamentals/security-descriptors/conditional-aces.md) 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.Code` references an attribute named exactly `Project.Code`.
- `@Resource.Department` references an attribute named exactly `Department`.

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_ACE` entries, 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](/peios/security-fundamentals/security-descriptors/the-sacl.md).

For the principal-side counterpart to resource attributes, read [Claims on a token](/peios/security-fundamentals/identity/claims.md).

---

# The SACL

_Peios / Peios Security Fundamentals / Security Descriptors_

> The system half of a security descriptor — audit and alarm ACEs, the integrity label, the PIP trust label, scoped policy references, and resource attributes.

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](/peios/security-fundamentals/security-descriptors/resource-attributes.md)). |
| `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](/peios/using-peios/constants-and-catalogs/ace-types-and-flags.md).

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](/peios/security-fundamentals/auditing/overview.md).

## 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-8192` for Medium, `S-1-16-12288` for High, and so on — any single-sub-authority `S-1-16` value 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](/peios/security-fundamentals/access-decisions/overview.md).

## 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-L` where `T` is the PIP type and `L` is 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](/peios/security-fundamentals/process-integrity-protection/overview.md).

## 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](/peios/security-fundamentals/central-access-policies/overview.md). 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](/peios/security-fundamentals/security-descriptors/resource-attributes.md).

## How the SACL is consumed

The access check consults the SACL twice, at different points:

1. **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.
2. **After the DACL walk**, the check scans the SACL again for `SYSTEM_AUDIT` and `SYSTEM_ALARM` ACEs 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 grants `ACCESS_SYSTEM_SECURITY` on any object), or
- `SeRestorePrivilege` (which grants it for specific `kacs_set_sd` use 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](/peios/security-fundamentals/security-descriptors/resource-attributes.md)).

## 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_sd` with `SACL_SECURITY_INFORMATION` — also requires `ACCESS_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](/peios/security-fundamentals/auditing/overview.md).

For how the SACL's labels and policies fit into the full check pipeline, read [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

To read and edit a SACL from a shell, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# The sd command

_Peios / Peios Security Fundamentals / Security Descriptors_

> The sd command reads and changes a file's security descriptor — owner, access rules, audit rules, integrity label, and inheritance.

`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`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) shows a file's owner and a summary, and [`cp --preserve`](/peios/using-peios/peiosutils/files-and-directories/cp.md) 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](/peios/security-fundamentals/identity/sids.md) 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](/peios/security-fundamentals/access-decisions/overview.md): "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](/peios/security-fundamentals/security-descriptors/conditional-aces.md), 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](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md).

### `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](/peios/security-fundamentals/security-descriptors/the-sacl.md). `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](/peios/security-fundamentals/security-descriptors/ownership.md).

## 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](/peios/security-fundamentals/access-decisions/mandatory-integrity-control.md).

## 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](/peios/security-fundamentals/security-descriptors/inheritance.md) 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 — gating a specific operation regardless of who you are, granted by this machine's policy at logon.

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 with `Se` and end with `Privilege`; 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](/peios/security-fundamentals/privileges/assigning-privileges.md) 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](/peios/security-fundamentals/privileges/lifecycle.md).

## 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.

- `SeLoadDriverPrivilege` gates kernel module loads. No DACL applies; either you have the privilege enabled or the load fails.
- `SeSystemtimePrivilege` gates `settimeofday`.
- `SeCreateTokenPrivilege` gates `kacs_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](/peios/security-fundamentals/privileges/categories.md):

- `SeSecurityPrivilege` grants `ACCESS_SYSTEM_SECURITY` (SACL read/write) regardless of the DACL.
- `SeTakeOwnershipPrivilege` grants `WRITE_OWNER` on any object.
- `SeBackupPrivilege` grants read access to any object (with the backup intent flag set).
- `SeRestorePrivilege` grants write access and ownership-change rights (with the restore intent flag).
- `SeRelabelPrivilege` permits 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](/peios/security-fundamentals/privileges/intent-gated.md).

## 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](/peios/security-fundamentals/privileges/lifecycle.md).

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](/peios/security-fundamentals/privileges/intent-gated.md).

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](/peios/security-fundamentals/privileges/categories.md).

If you want the full catalog of named privileges with their numeric LUIDs and one-line descriptions, that is in the [Constants and catalogs](/peios/using-peios/constants-and-catalogs/overview.md) reference.

---

# Privilege lifecycle

_Peios / Peios Security Fundamentals / Privileges_

> Each privilege on a token is absent, present-disabled, present-enabled, or used — the transitions between states, AdjustPrivileges, and why removal is one-way.

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](/peios/security-fundamentals/privileges/overview.md):

| 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_token` for 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:

1. Save previous state by calling AdjustPrivileges to enable the desired privilege.
2. Do the work.
3. 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_REMOVED` is applied, the privilege is absent. A subsequent attempt to enable it fails (the privilege is no longer present).
- **Toggle the `used` bit.** The kernel sets `used` automatically when a privilege is exercised. Userspace cannot clear it.
- **Set `enabled` without `present`.** 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:

- `present` is clear.
- `enabled` is clear.
- `enabled_by_default` is clear.
- `used` remains 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](/peios/security-fundamentals/tokens/lifecycle.md) and [Restricted and write-restricted tokens](/peios/security-fundamentals/tokens/restricted-tokens.md).

## 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](/peios/security-fundamentals/privileges/intent-gated.md).

For bulk privilege removal as part of building a sandbox token, read [Restricted and write-restricted tokens](/peios/security-fundamentals/tokens/restricted-tokens.md).

---

# Intent-gated privileges

_Peios / Peios Security Fundamentals / Privileges_

> SeBackup and SeRestore participate in a check only when the caller passes BACKUP_INTENT or RESTORE_INTENT — why intent gating exists and how the flags work.

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`, and `READ_CONTROL`.
- For registry keys: the read-category rights on the key (the specific values depend on the registry's GenericMapping).
- For tokens: `TOKEN_QUERY` and `READ_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_EA` for files; corresponding rights on other object types).
- `DELETE`.
- `WRITE_OWNER` and `WRITE_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:

1. Token at startup has `SeBackupPrivilege` present and enabled. (Granted by authd's privilege policy to backup-role principals.)
2. For each object to back up:
   - Call AccessCheck with `BACKUP_INTENT` set in `privilege_intent`. The kernel grants the read-category rights via SeBackup if the DACL would not.
   - Read the object.
3. 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:

1. Token at startup has `SeRestorePrivilege` present and enabled.
2. For each object to restore:
   - Call AccessCheck with `RESTORE_INTENT` set. 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.
3. 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](/peios/security-fundamentals/auditing/overview.md).

## Where to go next

For the four functional categories the rest of the privileges fall into, read [Privilege categories](/peios/security-fundamentals/privileges/categories.md).

For exactly where SeBackup and SeRestore fire during a check — and which layers can still strip their grants — read [Privileges in the pipeline](/peios/security-fundamentals/access-decisions/privileges-in-the-pipeline.md).

---

# Privilege categories

_Peios / Peios Security Fundamentals / Privileges_

> The four functional categories of privilege — kernel-standalone, AccessCheck-influencing, application-level, and reserved — and what each one does.

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](/peios/using-peios/constants-and-catalogs/overview.md).

## 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:

- `SeSecurityPrivilege` is consulted when AccessCheck sees `ACCESS_SYSTEM_SECURITY` in the requested mask.
- `SeBackupPrivilege` and `SeRestorePrivilege` are consulted near the start of AccessCheck, but only if the corresponding intent flag is set in `privilege_intent`. See [Intent-gated privileges](/peios/security-fundamentals/privileges/intent-gated.md).
- `SeTakeOwnershipPrivilege` is consulted after the DACL walk: if the walk did not grant `WRITE_OWNER` and the mandatory policy did not block it, the privilege grants it.
- `SeRelabelPrivilege` is consulted by `kacs_set_sd` when 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".

> [!IMPORTANT]
> **Not currently grantable.** `SeTakeOwnershipPrivilege`, `SeRelabelPrivilege` and `SeSystemProfilePrivilege` are enforced by the kernel exactly as described above and named in audit records, but they are missing from the published ABI headers that userspace is built against — so no policy can name them and `authd` cannot put them on a token. Everything on this page about how they behave is accurate; what is not yet true is that anyone can hold one. In practice that means **a Peios machine currently has no way to grant take-ownership**, so the usual escape hatch for a file whose DACL excludes you — take ownership, then rewrite the DACL — is unavailable.

## 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](/peios/security-fundamentals/privileges/assigning-privileges.md). `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](/peios/using-peios/constants-and-catalogs/overview.md). 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](/peios/using-peios/constants-and-catalogs/privilege-catalog.md).

For how the AccessCheck-influencing category actually participates in a check, read [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

---

# Assigning privileges

_Peios / Peios Security Fundamentals / Privileges_

> The policy records under Machine\Generic\Authn\Policy that authd reads at every logon — which privileges a principal gets, and at what integrity level.

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.
- **`Owner`** and **`DefaultDacl`** have 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](/peios/security-fundamentals/privileges/overview.md) — the model these records feed.
- [The token command](/peios/security-fundamentals/tokens/token-command.md) — reading the privileges and integrity a live token actually carries.
- [Managing local principals](/peios/security-fundamentals/managing-local-principals/overview.md) — the accounts these records apply to.

---

# Access decisions

_Peios / Peios Security Fundamentals / Access Decisions_

> AccessCheck takes a token, an object's security descriptor, and a desired access mask, and runs a multi-stage pipeline to produce a granted mask.

**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

```mermaid
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](/peios/security-fundamentals/privileges/intent-gated.md). |
| `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](/peios/security-fundamentals/security-descriptors/the-sacl.md). |
| Staging mismatch flag | Set when a staged CAAP policy would have produced a different result. See [Central access policies](/peios/security-fundamentals/central-access-policies/overview.md). |

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](/peios/security-fundamentals/privileges/intent-gated.md). |
| 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](/peios/security-fundamentals/access-decisions/privileges-in-the-pipeline.md). |
| 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](/peios/security-fundamentals/access-decisions/mandatory-integrity-control.md) and [Process integrity protection](/peios/security-fundamentals/process-integrity-protection/overview.md). |
| 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](/peios/security-fundamentals/security-descriptors/ownership.md). |
| 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](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md). |
| 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](/peios/security-fundamentals/security-descriptors/ownership.md). |
| 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](/peios/security-fundamentals/tokens/restricted-tokens.md). |
| 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](/peios/security-fundamentals/confinement/overview.md). |
| 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](/peios/security-fundamentals/central-access-policies/overview.md). |
| 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](/peios/security-fundamentals/auditing/overview.md). |
| 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](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md), 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](/peios/security-fundamentals/security-descriptors/acls-and-aces.md). 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](/peios/security-fundamentals/access-decisions/mandatory-integrity-control.md).

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](/peios/security-fundamentals/access-decisions/privileges-in-the-pipeline.md).

If you want the narrowing layers — restricted-token pass, confinement pass, CAAP — in detail, read [Narrowing layers](/peios/security-fundamentals/access-decisions/narrowing-layers.md).

If you are debugging an unexpected denial right now, read [Debugging a denial](/peios/security-fundamentals/access-decisions/debugging-a-denial.md).

---

# Mandatory integrity control

_Peios / Peios Security Fundamentals / Access Decisions_

> MIC gates access by integrity level — the object's label in its SACL against the token's own level, compared before the DACL walk ever runs.

**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](/peios/using-peios/constants-and-catalogs/other-constants.md)):

| 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-16` SID with one sub-authority — normally one of the five above, occasionally a non-standard level such as `medium-plus` or `protected`).
- 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](/peios/using-peios/constants-and-catalogs/ace-types-and-flags.md)):

| 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

```mermaid
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:

1. Extract the object's integrity label (or apply the Medium-with-NO_WRITE_UP default if there is no label ACE).
2. Compare the token's `integrity_level` against the object's label.
3. If the token's level is at least the object's level, MIC is satisfied. Nothing happens; the pipeline proceeds.
4. 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 `decided` set but not `granted`.

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](/peios/security-fundamentals/privileges/assigning-privileges.md) 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](/peios/security-fundamentals/process-integrity-protection/overview.md).

**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](/peios/security-fundamentals/impersonation/the-two-gates.md) 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 (`SeSecurityPrivilege` or `SeRestorePrivilege`).
- **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's `pip_type` and `pip_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_ACE` are not PIP-protected; objects without a `SYSTEM_MANDATORY_LABEL_ACE` get 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](/peios/security-fundamentals/access-decisions/privileges-in-the-pipeline.md).

For the binary-trust axis MIC is so often compared with, read [Process integrity protection](/peios/security-fundamentals/process-integrity-protection/overview.md).

---

# Privileges in the pipeline

_Peios / Peios Security Fundamentals / Access Decisions_

> Where each AccessCheck-influencing privilege fires in the pipeline, which bits it grants, what it does not bypass, and how its grant is recorded for audit.

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](/peios/security-fundamentals/privileges/intent-gated.md). 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](/peios/security-fundamentals/access-decisions/overview.md)). 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](/peios/security-fundamentals/privileges/intent-gated.md).

## 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_EA` for files; corresponding bits for other types).
- `DELETE`.
- `WRITE_OWNER` and `WRITE_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_sd` with `LABEL_SECURITY_INFORMATION` (or `SACL_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_OWNER` when the caller is operating on an object with a higher integrity label — specifically, the privilege "punches `WRITE_OWNER` through 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](/peios/security-fundamentals/confinement/overview.md).

**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 in `audit_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](/peios/security-fundamentals/auditing/overview.md).

## 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](/peios/security-fundamentals/access-decisions/narrowing-layers.md).

For the privilege-use events themselves and where they end up, read [Auditing](/peios/security-fundamentals/auditing/overview.md).

---

# Narrowing layers

_Peios / Peios Security Fundamentals / Access Decisions_

> The three layers that can only remove access — the restricted-token pass, confinement, and central access policies — in pipeline order, and what bypasses each.

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](/peios/security-fundamentals/tokens/restricted-tokens.md) 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_capabilities` list.
- 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](/peios/security-fundamentals/confinement/overview.md).

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](/peios/security-fundamentals/central-access-policies/overview.md). 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:

```mermaid
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](/peios/security-fundamentals/access-decisions/debugging-a-denial.md).

For the token-side mechanics of the restricted pass, read [Restricted and write-restricted tokens](/peios/security-fundamentals/tokens/restricted-tokens.md).

---

# Debugging a denial

_Peios / Peios Security Fundamentals / Access Decisions_

> An access was denied and you need to know why — the systematic walk through the pipeline, what to inspect, in what order, and what each finding means.

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:

1. **The token cannot be used.** The thread is impersonating at the Identification level (step 0).
2. **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).
3. **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).
4. **The DACL did not grant it.** The walk produced an empty result for the requested bits (step 8).
5. **A narrowing layer stripped it.** Restricted-token pass, confinement, or CAAP removed bits the DACL had granted (steps 10–12).
6. **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>/token` produces a query-only handle (or use `kacs_open_thread_token` programmatically). The handle can be queried with `KACS_IOC_QUERY` for fields including `user_sid`, `groups`, `integrity_level`, `privileges`, `restricted_sids`, `confinement_sid`, `auth_id`, and `impersonation_level`.
- The thread's effective token (impersonation if installed, primary otherwise): `cat /sys/kernel/security/kacs/self` when run from the thread itself; for another thread, the same `/proc/<pid>/task/<tid>/token` path 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](/peios/security-fundamentals/impersonation/the-two-gates.md).
- **`integrity_level` is low and the object is high** — possible MIC denial; check the object's label next.
- **`restricted_sids` is non-empty** — the token is restricted. The denial may be from the restricted-token pass; check what SIDs are in the restricted list.
- **`confinement_sid` is set and `confinement_exempt` is false** — the token is confined. The denial may be from the confinement pass.
- **A privilege you expected to be enabled is in the `present` set but not the `enabled` set** — 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_sd` on 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 implicit `READ_CONTROL | WRITE_DAC` is suppressed; the owner gets only what that ACE grants.
- **The DACL.** What ACEs are present, in what order? Are there any `INHERIT_ONLY` ACEs that look like they should grant access but are actually skipped during the walk?
- **The mandatory integrity label.** Look for a `SYSTEM_MANDATORY_LABEL_ACE` in 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_ACE` in the SACL marks the object as PIP-protected.
- **CAAP references.** `SYSTEM_SCOPED_POLICY_ID_ACE` entries point to central access policies that contribute to the decision.
- **Resource attributes.** `SYSTEM_RESOURCE_ATTRIBUTE_ACE` entries. 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](/peios/security-fundamentals/impersonation/the-two-gates.md)).

### 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](/peios/security-fundamentals/process-integrity-protection/overview.md).

### 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_intent` to 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_ONLY` ACEs.** Are there ACEs that look relevant but have the `IO` flag set? They are skipped during the walk.
- **The matching identity.** Does the caller match the SIDs in the ACEs? Remember:
  - The token's `user_sid` matches.
  - Each group in `groups` with `SE_GROUP_ENABLED` set matches for allow ACEs.
  - Groups with `SE_GROUP_USE_FOR_DENY_ONLY` set match for deny ACEs only.
  - The well-known SIDs Everyone, Authenticated Users, etc. match according to their semantics.
  - `OWNER RIGHTS` and `PRINCIPAL_SELF` match per the [virtual group injection](/peios/security-fundamentals/security-descriptors/ownership.md) rules.
- **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_sids` is 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_sid` and `confinement_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_ACE` entries, 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:

1. Find the thread (`tid`, `pid`).
2. Read the effective token. Note `user_sid`, `integrity_level`, `restricted_sids`, `confinement_sid`, `impersonation_level`, and which privileges are present-and-enabled.
3. 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.
4. Walk the pipeline mentally against these inputs.
5. 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](/peios/security-fundamentals/inspecting/overview.md).

To rehearse an access check from a shell against a real file and token, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

---

# Central access policies

_Peios / Peios Security Fundamentals / Central Access Policies_

> A central access policy is defined once and applied to many objects — referenced by SID from the SACL, evaluated at access-check time, and only ever narrowing.

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](/peios/security-fundamentals/security-descriptors/the-sacl.md)). 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](/peios/security-fundamentals/central-access-policies/staged-policies.md).

The full rule structure is in [Policies and rules](/peios/security-fundamentals/central-access-policies/policies-and-rules.md). The evaluation mechanics — how rules compose, where they fire in the access pipeline, the no-recursion rule — are in [Evaluation](/peios/security-fundamentals/central-access-policies/evaluation.md).

## 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)](/peios/security-fundamentals/file-access/the-handle-model.md) 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](/peios/security-fundamentals/central-access-policies/distribution-and-recovery.md)). 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](/peios/security-fundamentals/central-access-policies/policies-and-rules.md).

If you want to know how CAAP fires in the access pipeline and the rules for composition, read [Evaluation](/peios/security-fundamentals/central-access-policies/evaluation.md).

If you want to understand the testing model — staged policies that evaluate in parallel without affecting access — read [Staged policies](/peios/security-fundamentals/central-access-policies/staged-policies.md).

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](/peios/security-fundamentals/central-access-policies/distribution-and-recovery.md).

---

# Policies and rules

_Peios / Peios Security Fundamentals / Central Access Policies_

> A policy is a versioned bundle of rules — each with an optional applies-to expression, an effective DACL and SACL, and staged versions for testing.

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](/peios/security-fundamentals/central-access-policies/evaluation.md).

## 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](/peios/security-fundamentals/central-access-policies/distribution-and-recovery.md)).

## 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](/peios/using-peios/wire-formats-reference/overview.md); 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](/peios/security-fundamentals/security-descriptors/conditional-aces.md)). 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](/peios/security-fundamentals/security-descriptors/acls-and-aces.md)). 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](/peios/security-fundamentals/central-access-policies/evaluation.md)). 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](/peios/security-fundamentals/central-access-policies/staged-policies.md).

## 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 | FAILED_ACCESS_ACE_FLAG` — log every attempt to access a TopSecret object. |
| `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](/peios/security-fundamentals/central-access-policies/evaluation.md).

For the staged DACL and SACL fields and the rollout pattern they enable, read [Staged policies](/peios/security-fundamentals/central-access-policies/staged-policies.md).

For the byte-level layout these structures are encoded in, read [CAAP format](/peios/using-peios/wire-formats-reference/caap-format.md).

---

# Evaluation

_Peios / Peios Security Fundamentals / Central Access Policies_

> CAAP runs at step 12 — each referenced policy looked up, each applicable rule's effective DACL intersected with the running grant, with no recursion.

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

```mermaid
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:

1. **Scan the object's SACL** for `SYSTEM_SCOPED_POLICY_ID_ACE` entries that are not inherit-only.
2. **For each such ACE**, take the SID and look it up in the kernel's policy cache.
3. **If the policy is not in the cache**, apply the recovery policy — a hardcoded fallback that grants `GENERIC_ALL` only to `BUILTIN\Administrators`, `SYSTEM`, and `OWNER_RIGHTS`. The intersection happens just like any other CAAP rule's would. See [Distribution and recovery](/peios/security-fundamentals/central-access-policies/distribution-and-recovery.md).
4. **If the policy is in the cache**, iterate through its rules.
5. **For each rule**, evaluate the `applies_to` expression 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.
6. **For each applicable rule**, evaluate the `effective_dacl` against 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.
7. **Intersect** the rule's grant with the running grant. Bits not present in both are dropped from the running grant.
8. **Collect the rule's `effective_sacl`** for the eventual audit walk at step 14.
9. **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](/peios/security-fundamentals/central-access-policies/staged-policies.md).
10. **Move on** to the next rule (or, when all rules are evaluated, the next policy).
11. **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_ACE` entries** 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](/peios/security-fundamentals/access-decisions/narrowing-layers.md) 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](/peios/security-fundamentals/auditing/overview.md).

## 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](/peios/security-fundamentals/central-access-policies/policies-and-rules.md) 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](/peios/security-fundamentals/file-access/the-handle-model.md).

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](/peios/security-fundamentals/central-access-policies/distribution-and-recovery.md).

## Where to go next

For the parallel evaluation of proposed policy changes and the staging mismatch flag, read [Staged policies](/peios/security-fundamentals/central-access-policies/staged-policies.md).

For how policies reach the kernel's cache and what happens when a referenced policy is missing, read [Distribution and recovery](/peios/security-fundamentals/central-access-policies/distribution-and-recovery.md).

For how the CAAP intersection composes with the restricted-token and confinement passes, read [Narrowing layers](/peios/security-fundamentals/access-decisions/narrowing-layers.md).

---

# Staged policies

_Peios / Peios Security Fundamentals / Central Access Policies_

> A rule's staged DACL and SACL are evaluated in parallel without affecting access — the staging mismatch flag shows where a proposed change would differ.

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:

1. 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.
2. If the applies-to returned TRUE, the `effective_dacl` is evaluated against the calling token. The result is intersected with the running grant.
3. 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.
4. 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.
5. The same comparison happens for the `staged_sacl` versus `effective_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:

1. **Author the staged change.** Take the existing policy. Add or modify the `staged_dacl` and/or `staged_sacl` to reflect the proposed change. Leave the effective versions unchanged.
2. **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.
3. **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.
4. **Collect mismatch reports.** A monitoring tool records mismatches and their contexts (which call, which token, which object, which requested rights). Administrators review the report.
5. **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.
6. **Commit.** Replace the policy with one where `effective_dacl` and `effective_sacl` hold what the staged versions held. Push via `kacs_set_caap`. The change is now live; subsequent access checks use the new effective version.
7. **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](/peios/security-fundamentals/central-access-policies/distribution-and-recovery.md).

For the `caap-policy-diagnostic` event that carries the staging mismatch, read [Events and transport](/peios/security-fundamentals/auditing/events-and-transport.md).

---

# Distribution and recovery

_Peios / Peios Security Fundamentals / Central Access Policies_

> authd pushes policies into the kernel's cache via kacs_set_caap — empty at boot, a recovery policy for missing references, replication as authd's job.

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:

1. 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.
2. Parses the spec into the cache's internal representation.
3. Replaces any existing entry at the policy's SID with the new one. If no entry existed, creates one.
4. 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_caap` to 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\Administrators`
- `SYSTEM`
- `OWNER_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:

1. Kernel initialises. SYSTEM token created. Anonymous token created.
2. peinit starts. peinit may push early-boot policies via `kacs_set_caap` if any exist.
3. authd starts. authd connects to its policy source and pushes every policy via `kacs_set_caap`.
4. authd signals that policy distribution is complete (typically by reaching a steady state).
5. 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](/peios/security-fundamentals/boot-and-trust-establishment/overview.md).

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_caap` is 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_ACE` to 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](/peios/security-fundamentals/central-access-policies/evaluation.md).

For the boot-time coordination that decides when the CAAP cache is ready, read [Boot and trust establishment](/peios/security-fundamentals/boot-and-trust-establishment/overview.md).

---

# File access

_Peios / Peios Security Fundamentals / File access_

> FACS applies KACS access control to files. AccessCheck runs once at open; the granted mask is cached on the fd and gates every later operation.

**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](/peios/security-fundamentals/file-access/the-handle-model.md).

## 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](/peios/security-fundamentals/central-access-policies/overview.md) 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:

```mermaid
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](/peios/using-peios/mount-policies/overview.md).
- **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](/peios/security-fundamentals/file-access/opening-files.md).

## 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](/peios/security-fundamentals/file-access/the-handle-model.md).

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](/peios/security-fundamentals/file-access/opening-files.md).

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](/peios/security-fundamentals/file-access/managing-file-security.md).

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](/peios/security-fundamentals/file-access/special-cases.md).

---

# The handle model

_Peios / Peios Security Fundamentals / File access_

> Every FACS-managed fd carries a granted access mask, stamped at open and immutable. What the cache contains, which operations consult it, and fd transfer.

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](/peios/security-fundamentals/security-descriptors/the-sacl.md) and [Audit ACEs](/peios/security-fundamentals/auditing/audit-aces.md).

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](/peios/security-fundamentals/file-access/special-cases.md)).

## 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 `unlink` of 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 `splice` between 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:

> [!WARNING]
> **Don't expect tightening to revoke open handles.** A security incident response that revokes a permission needs to also close fds. The kernel does not.

**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](/peios/security-fundamentals/file-access/opening-files.md).

For reading and writing the SD behind a handle, read [Managing file security](/peios/security-fundamentals/file-access/managing-file-security.md).

For the edges of the model — O_PATH, the exec dual gate, append-only handles — read [Special cases](/peios/security-fundamentals/file-access/special-cases.md).

---

# Opening files

_Peios / Peios Security Fundamentals / File access_

> kacs_open with an explicit desired mask versus legacy openat with POSIX flags — strict-mode grants, create dispositions, MAXIMUM_ALLOWED, and SD-at-creation.

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:

1. Resolves the path.
2. Decides whether the file exists / should exist based on `create_disposition`.
3. Runs AccessCheck with the calling token, the file's SD (or computes one for a newly-created file), and the `desired_access` mask.
4. **Strict-mode semantics**: every bit in `desired_access` must 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.
5. On success, returns the fd with the granted mask cached. If `status` was 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](/peios/using-peios/constants-and-catalogs/other-constants.md)):

| 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](/peios/security-fundamentals/security-descriptors/inheritance.md)).
- 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:

1. The `MAXIMUM_ALLOWED` flag is stripped from the desired mask.
2. AccessCheck runs in maximum-allowed mode (see [DACL evaluation](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md)).
3. The full DACL is walked, accumulating every right the caller could have been granted.
4. 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](/peios/security-fundamentals/file-access/the-handle-model.md) — what the cached mask these opens produce actually does.
- [Managing file security](/peios/security-fundamentals/file-access/managing-file-security.md) — reading and writing the SD behind a handle.
- [DACL evaluation](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md) — the maximum-allowed walk MAXIMUM_ALLOWED relies on.

---

# Managing file security

_Peios / Peios Security Fundamentals / File access_

> Reading and writing file SDs with kacs_get_sd and kacs_set_sd — the security_information bitmask, per-component access rules, and the LABEL flag.

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_CONTROL` to read, `WRITE_DAC` / `WRITE_OWNER` / `ACCESS_SYSTEM_SECURITY` to write different parts. Routing through `kacs_get_sd` / `kacs_set_sd` puts 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:

1. Resolves the path.
2. Runs AccessCheck — the caller needs the access rights corresponding to the requested components (covered below).
3. Reads the file's SD from its filesystem-native storage.
4. Constructs a **subset SD** containing only the requested components. Other components have offset 0 in the header and the corresponding PRESENT bit is clear.
5. 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](/peios/using-peios/constants-and-catalogs/other-constants.md).)

`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:

1. Resolves the path.
2. Parses the SD blob. Rejects malformed SDs (size limit, bad ACL structure, etc.) with `-EINVAL`.
3. Runs AccessCheck for the required rights per the components being updated.
4. 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).
5. Writes the SD to the file's native storage.
6. 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](/peios/security-fundamentals/security-descriptors/ownership.md)). `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`, not `ACCESS_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_RELATIVE` flag 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](/peios/security-fundamentals/security-descriptors/ownership.md):

- The new owner must be the caller's own user_sid, **or** a SID in the caller's groups with `SE_GROUP_OWNER` set, **or** the caller must hold `SeRestorePrivilege`.
- The caller must have `WRITE_OWNER` on the object, or hold `SeTakeOwnershipPrivilege`.

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 includes `WRITE_DAC` (legacy opens request it as a compat right); otherwise `-EACCES` (`-EBADF` on O_PATH fds).
- `chmod()` runs a fresh access check requiring `WRITE_DAC` on 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_sd` with 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](/peios/security-fundamentals/security-descriptors/ownership.md) — the owner-transfer rules kacs_set_sd enforces.
- [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md) — the shell wrapper for these syscalls.
- [Opening files](/peios/security-fundamentals/file-access/opening-files.md) — supplying an SD at file creation instead.

---

# Special cases

_Peios / Peios Security Fundamentals / File access_

> The edges of FACS — O_PATH, the exec dual gate, append-only handles, the inert sticky bit, replaced POSIX ACLs, NFS dual authority, and whiteouts.

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:

1. Runs a fresh AccessCheck against the file's current DACL using the caller's current effective token.
2. Requires both the **`+x` mode bit** on the file and **`FILE_EXECUTE`** on the caller's access to be present.

The two checks answer two different questions:

- **`+x` on 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 `+x` is data; trying to exec it fails regardless of who is asking, because exec on data is meaningless.
- **`FILE_EXECUTE` on 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.
- `ftruncate` to extend the file is denied (cannot rewind via truncate).
- `fallocate` modes 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_access` and `system.posix_acl_default` xattrs 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 includes `WRITE_OWNER`; otherwise `-EACCES`.
- `chown()` and `lchown()` run a fresh access check requiring `WRITE_OWNER` on 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](/peios/security-fundamentals/file-access/managing-file-security.md)) 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](/peios/using-peios/mount-policies/overview.md). 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.

> [!WARNING]
> **Don't trust FACS results on NFS for security purposes.** The local FACS decision is "the client will let this go forward"; the server may still refuse. If you need a security guarantee, the server's access control is what matters.

Other implications:

- **Expect I/O errors from server-side denial.** A successful open does not guarantee a successful read. Handle `EACCES` (or `EIO`) 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 `/proc` files (`/proc/uptime`, `/proc/cpuinfo`) are world-readable by convention.
- `/sys` writes are restricted to `BUILTIN\Administrators` and `SYSTEM` by 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](/peios/using-peios/mount-policies/overview.md). `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](/peios/security-fundamentals/file-access/the-handle-model.md).

For how a mount's policy decides whether FACS applies at all, read [Mount policies](/peios/using-peios/mount-policies/overview.md).

For the SD read/write syscalls referenced throughout, read [Managing file security](/peios/security-fundamentals/file-access/managing-file-security.md).

---

# Confinement

_Peios / Peios Security Fundamentals / Confinement_

> Confinement is the Peios sandbox model — policy applied to an application from outside that narrows what its token can reach, past even what privileges bypass.

**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](/peios/security-fundamentals/tokens/restricted-tokens.md). 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](/peios/security-fundamentals/confinement/capabilities-and-modes.md).

## The model in one paragraph

A confined token's effective access is the **intersection** of:
1. What the DACL plus privileges would grant the token's full identity.
2. 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](/peios/security-fundamentals/confinement/the-confinement-pass.md). The capability matching specifically (how the SIDs in `confinement_capabilities` are compared against ACEs in DACLs) is in [Capabilities and modes](/peios/security-fundamentals/confinement/capabilities-and-modes.md).

## 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 `SeShutdown` can 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](/peios/security-fundamentals/confinement/capabilities-and-modes.md).

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](/peios/security-fundamentals/confinement/the-confinement-pass.md).

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](/peios/security-fundamentals/confinement/positive-confinement.md). 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 as capability SIDs — the well-known set, SHA-256-derived custom capabilities, and the normal and strict confinement modes.

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:

1. The application's manifest declares the capability by name, so authd can compute the SID and put it on the token.
2. 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_sid` matches an ACE whose SID is the same.
- Each entry in `confinement_capabilities` matches an ACE whose SID is the same.
- `ALL_APPLICATION_PACKAGES` matches an ACE on that SID **only if** the token's capabilities include it (i.e. only in normal mode).
- `ALL_RESTRICTED_APPLICATION_PACKAGES` matches 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](/peios/security-fundamentals/confinement/the-confinement-pass.md).

## 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](/peios/security-fundamentals/confinement/positive-confinement.md) — 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_sid` and `groups`, not its `confinement_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\Administrators` `GENERIC_ALL`. (Administrative full control.)
- An ACE granting `SYSTEM` `GENERIC_ALL`. (System full control.)
- An ACE granting `Authenticated Users` `GENERIC_READ`. (Standard authenticated read.)
- An ACE granting `internetClient` `FILE_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](/peios/security-fundamentals/confinement/the-confinement-pass.md).

For the other use of the same capability SIDs — placed in `groups` as positive grants — read [Positive confinement](/peios/security-fundamentals/confinement/positive-confinement.md).

For the wider catalog of system-defined SIDs these capabilities sit alongside, read [Well-known principals](/peios/security-fundamentals/identity/well-known-principals.md).

---

# The confinement pass

_Peios / Peios Security Fundamentals / Confinement_

> Confinement fires at step 11 as an absolute intersection — the DACL re-walked against the confinement identity, with nothing restored afterwards.

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_sid` is non-null, **and**
- The token's `confinement_exempt` flag 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

```mermaid
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 | WRITE_DAC` for free. |
| 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`, or `SeTakeOwnershipPrivilege` in 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_sid` or `groups` for any kernel API other than AccessCheck. A confined process still appears as its original user in `getpwuid`-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 `stat` on 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:

1. Whatever the DACL walk + privileges produced (steps 4–9).
2. Whatever the restricted-token walk would have produced (step 10, if `restricted_sids` is non-empty).
3. Whatever the confinement walk would have produced (step 11, this page).
4. 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](/peios/security-fundamentals/access-decisions/narrowing-layers.md) for the composition rules across all three intersections.

## A worked example

A service is deployed under confinement. Its token has:

- `user_sid = jellyfin_user_SID`
- `groups = [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`

The access check:

1. 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.
2. Step 5: no MIC label (default Medium / NO_WRITE_UP — does not block read), no PIP label. Nothing pre-decided.
3. Step 6: virtual group injection. `OWNER RIGHTS` is added (the token owns the file). But the object's DACL has no OWNER RIGHTS ACE, so the implicit `READ_CONTROL | WRITE_DAC` will be granted at step 8.
4. 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.
5. Step 10: restricted-token pass skipped (no restricted_sids).
6. 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 `internetClient` matches no ACE.
   - The capability `removableStorage` matches no ACE.
   - The capability `ALL_APPLICATION_PACKAGES` matches 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).
7. Step 12: no CAAP.
8. 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](/peios/security-fundamentals/confinement/positive-confinement.md).

For how the confinement intersection composes with the restricted-token and CAAP intersections, read [Narrowing layers](/peios/security-fundamentals/access-decisions/narrowing-layers.md).

---

# Positive confinement

_Peios / Peios Security Fundamentals / Confinement_

> A capability SID placed in a token's normal groups grants access through the ordinary DACL walk — the canonical pattern for service access at scale.

**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](/peios/security-fundamentals/confinement/capabilities-and-modes.md) — `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 `jellyfin` user, mentioned in every media-file DACL.
- A `transmission` user, mentioned in every download-directory DACL.
- A `nextcloud` user, mentioned in every shared-storage DACL.

You define **capabilities** — semantic units of access — and grant ACEs to *those*:

- A `media-library-read` capability. Every media file's DACL grants `GENERIC_READ` to this capability.
- A `download-write` capability. Every download directory's DACL grants `GENERIC_WRITE` to this capability.
- A `shared-storage` capability. 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 `groups` does 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_ALL`
- `ACCESS_ALLOWED  BUILTIN\Administrators  GENERIC_ALL`
- `ACCESS_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](/peios/security-fundamentals/confinement/the-confinement-pass.md).

For the ordinary DACL walk that consumes capability SIDs placed in `groups`, read [DACL evaluation](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md).

---

# Process integrity protection

_Peios / Peios Security Fundamentals / Process Integrity Protection_

> PIP gates operations between processes by the trust of their binaries — a two-dimensional label set by the kernel at exec from the binary's signature.

**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](/peios/security-fundamentals/process-integrity-protection/the-process-security-descriptor.md). |
| Mitigation flags | The process's enabled security mitigations, including `NO_CHILD` (whether the process may fork) — see [Process mitigations](/peios/security-fundamentals/process-mitigations/overview.md). |

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](/peios/using-peios/constants-and-catalogs/well-known-sids.md). 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:

1. The kernel reads the binary about to be exec'd.
2. It looks for a signature — an ELF `.peios.sig` section or a `security.peios.sig` xattr.
3. It verifies the signature against its compiled-in public key catalog.
4. On a valid signature, the PIP fields are set to whatever level the catalog says the signing key represents.
5. On no signature, invalid signature, or any verification failure, `pip_type` is set to None (0) and `pip_trust` to 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](/peios/security-fundamentals/binary-signing/overview.md).

## 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](/peios/security-fundamentals/process-integrity-protection/the-two-check-rule.md). 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](/peios/security-fundamentals/process-integrity-protection/the-process-security-descriptor.md).

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](/peios/security-fundamentals/process-integrity-protection/the-two-check-rule.md).

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](/peios/security-fundamentals/process-integrity-protection/pip-in-practice.md).

---

# The process security descriptor

_Peios / Peios Security Fundamentals / Process Integrity Protection_

> Every process carries its own security descriptor on the PSB, gating who may signal, debug, inspect, or modify the running process.

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](/peios/security-fundamentals/process-integrity-protection/the-two-check-rule.md).

## 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](/peios/security-fundamentals/security-descriptors/overview.md)). 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](/peios/using-peios/constants-and-catalogs/access-mask-bits.md); 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](/peios/using-peios/constants-and-catalogs/access-mask-bits.md).

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:

1. **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.
2. **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_INSTALL` of 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.
3. **The default template**, when used, grants the process's user identity `PROCESS_QUERY_LIMITED` and `PROCESS_QUERY_INFORMATION`, grants `BUILTIN\Administrators` and `SYSTEM` the 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](/peios/security-fundamentals/security-descriptors/overview.md) 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_READ` by 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](/peios/security-fundamentals/process-integrity-protection/the-two-check-rule.md).
- **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_token` requires `PROCESS_QUERY_INFORMATION` on 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](/peios/security-fundamentals/process-integrity-protection/the-two-check-rule.md).

For the general SD model these fields come from — owner, DACL, SACL, and the modification rules — read [Security descriptors](/peios/security-fundamentals/security-descriptors/overview.md).

---

# The two-check rule

_Peios / Peios Security Fundamentals / Process Integrity Protection_

> Every cross-process operation runs an SD check and a PIP dominance check — both must pass, and no privilege bypasses the PIP half.

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

```mermaid
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:

- **`SeTcbPrivilege`** does 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 (holding `SeTcbPrivilege`) is typically also a TCB-trust-level process anyway, so the dominance is satisfied by virtue of the binary, not the privilege.
- **`SeBackupPrivilege` and `SeRestorePrivilege`** do not bypass PIP. A backup tool granted access to read another process's memory via the SD check still needs to dominate PIP.
- **`SeImpersonatePrivilege`** does 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](#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 `-ESRCH` in some no-trust-revealing paths).
- **pidfd_open**, `pidfd_getfd` return `-EACCES`.
- **/proc/<pid>/*** reads return `-EACCES` or, 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](/peios/advanced-peios/linux-compatibility/credential-projection.md) 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](/peios/security-fundamentals/process-integrity-protection/pip-in-practice.md).

For where PIP's checks sit in the wider AccessCheck pipeline, read [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

---

# PIP in practice

_Peios / Peios Security Fundamentals / Process Integrity Protection_

> PIP's practical edges — the impersonation asymmetry, peinit as lifecycle manager, MIC versus PIP, the threat-model ceiling, and the v0.20 limits.

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`**: grants `WRITE_OWNER` regardless 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/mem` access bypasses PIP if writable.** Even reading `/dev/mem` gives access to physical memory. The defence is `CONFIG_STRICT_DEVMEM=y`, which restricts `/dev/mem` to 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 = 1024` is 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 `/proc` `getdents()` 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)` while `pip_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 = 1536` in 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](/peios/security-fundamentals/binary-signing/overview.md).

For the rule that gives PIP its bite on every cross-process operation, read [The two-check rule](/peios/security-fundamentals/process-integrity-protection/the-two-check-rule.md).

---

# Binary signing

_Peios / Peios Security Fundamentals / Binary Signing_

> A signed binary carries an ML-DSA-65 signature the kernel verifies at exec to set its PIP level — signing only adds trust, never blocks execution.

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](/peios/security-fundamentals/process-integrity-protection/overview.md) — 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_type` and `pip_trust` fields 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](/peios/security-fundamentals/process-mitigations/overview.md) (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](/peios/security-fundamentals/binary-signing/verification-and-pinning.md).

## 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](/peios/security-fundamentals/binary-signing/signature-format.md).

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](/peios/security-fundamentals/binary-signing/verification-and-pinning.md).

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](/peios/security-fundamentals/binary-signing/keys-and-image-build.md).

---

# Signature format

_Peios / Peios Security Fundamentals / Binary Signing_

> The 3310-byte signature blob — a version byte plus an ML-DSA-65 signature over a SHA-256 hash — and where it lives for ELF and non-ELF files.

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](/peios/security-fundamentals/binary-signing/keys-and-image-build.md).

## 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](/peios/security-fundamentals/binary-signing/verification-and-pinning.md)), 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](/peios/security-fundamentals/binary-signing/overview.md) — what the signature accomplishes.
- [Verification and pinning](/peios/security-fundamentals/binary-signing/verification-and-pinning.md) — how the kernel consumes this format at exec.
- [Keys and image build](/peios/security-fundamentals/binary-signing/keys-and-image-build.md) — who produces these blobs and with what key.

---

# Verification and pinning

_Peios / Peios Security Fundamentals / Binary Signing_

> How the kernel verifies at exec — the flow, the stable-snapshot rule, scripts and interpreters, symlinks, and inode pinning after verification.

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

```mermaid
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:

1. **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.
2. **The kernel looks for a signature.** For ELF binaries, it scans the section header table for `.peios.sig`. For non-ELF binaries, it reads the `security.peios.sig` xattr. If neither is present, the binary is unsigned.
3. **The kernel computes the content hash.** SHA-256 over the binary, following the rules from [Signature format](/peios/security-fundamentals/binary-signing/signature-format.md) — section contents zeroed for ELF, full file for non-ELF.
4. **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.
5. **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.
6. **Or, on any failure**, the PSB's PIP fields are set to `pip_type = None, pip_trust = 0`. The exec proceeds.
7. **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.sig` is 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:

1. Place the new (signed) binary at a different path.
2. 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.
3. 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](/peios/security-fundamentals/binary-signing/keys-and-image-build.md).

For the exact blob layout and content-hash rules verification relies on, read [Signature format](/peios/security-fundamentals/binary-signing/signature-format.md).

For what the assigned PIP fields go on to enforce, read [Process integrity protection](/peios/security-fundamentals/process-integrity-protection/overview.md).

---

# Keys and image build

_Peios / Peios Security Fundamentals / Binary Signing_

> The public-key catalog compiled into the kernel, the TCB private key held only by image builders, and the peiso signing flow.

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](/peios/security-fundamentals/process-integrity-protection/overview.md) — 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.

> [!CAUTION]
> **The TCB private key must never be present on any running Peios system.** It is for image build, and only image build. A running system has no need for the private key — the kernel verifies with the public key only.

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:

1. 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).
2. Collects the binaries that should be signed at TCB level. The full list is defined per release — typically peinit, authd, loregd, lpsd, eventd.
3. For each binary, peiso:
   - Computes the appropriate content hash (ELF section zeroed for ELF, full file for non-ELF — see [Signature format](/peios/security-fundamentals/binary-signing/signature-format.md)).
   - 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.sig` section (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 `.sig` file next to the binary. At image-assembly time, peiso reads the detached file and stamps the `security.peios.sig` xattr on the binary in the image's filesystem.
4. The signed binaries are placed in the image. The detached `.sig` files are discarded.
5. 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 `.peipkg` signed 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](/peios/security-fundamentals/binary-signing/verification-and-pinning.md).

For the per-process hardening flags that build on signing — LSV gates executable mappings by signature trust — read [Process mitigations](/peios/security-fundamentals/process-mitigations/overview.md).

---

# Process mitigations

_Peios / Peios Security Fundamentals / Process Mitigations_

> A mitigation is a one-way, kernel-enforced hardening flag on the PSB — restricting what a process may do with its own memory, control flow, and children.

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](/peios/security-fundamentals/process-integrity-protection/overview.md). 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_INFORMATION` on 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](/peios/security-fundamentals/process-mitigations/applying-and-lifecycle.md).

## 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 `mprotect` calls 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](/peios/security-fundamentals/process-mitigations/catalog.md).

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](/peios/security-fundamentals/process-mitigations/applying-and-lifecycle.md).

---

# Catalog

_Peios / Peios Security Fundamentals / Process Mitigations_

> The eight active mitigations — LSV, WXP, TLP, CFIF, CFIB, PIE, SML, NO_CHILD — what each one gates, what it blocks, and when to enable it.

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](/peios/using-peios/constants-and-catalogs/other-constants.md).

## 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](/peios/security-fundamentals/process-mitigations/overview.md) — the model these flags share.
- [Applying and lifecycle](/peios/security-fundamentals/process-mitigations/applying-and-lifecycle.md) — setting the flags and how they propagate.
- [Binary signing](/peios/security-fundamentals/binary-signing/overview.md) — the signatures LSV verifies against.

---

# Applying and lifecycle

_Peios / Peios Security Fundamentals / Process Mitigations_

> Mitigations are set via kacs_set_psb — free on your own process, privileged on another — and persist one-way through fork and exec.

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_psb` call 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_INFORMATION` on the target process — granted by the target's process SD.
- PIP dominance over the target (per the [two-check rule](/peios/security-fundamentals/process-integrity-protection/the-two-check-rule.md)).

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_psb` on 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](/peios/security-fundamentals/process-mitigations/catalog.md).

For the SD-plus-dominance rules that gate setting mitigations on another process, read [The two-check rule](/peios/security-fundamentals/process-integrity-protection/the-two-check-rule.md).

---

# Auditing

_Peios / Peios Security Fundamentals / Auditing_

> Auditing records what happened, separate from what was allowed. Audit ACEs, token audit policy, and delivery through KMES to userspace consumers.

**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](/peios/security-fundamentals/auditing/audit-aces.md). Privilege-use audit and the token-forced subset of object-access audit are covered in [Policy-forced auditing](/peios/security-fundamentals/auditing/policy-forced-auditing.md). Event schemas and the KMES transport are in [Events and transport](/peios/security-fundamentals/auditing/events-and-transport.md).

## Where audit fits in the pipeline

```mermaid
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](/peios/security-fundamentals/auditing/events-and-transport.md).

## 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.
- **`eventd`** is 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 `eventd` has 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_policy` may 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](/peios/security-fundamentals/auditing/audit-aces.md).

If you want privilege-use audit and the token-forced audit policy — events that fire independent of any SACL ACE — read [Policy-forced auditing](/peios/security-fundamentals/auditing/policy-forced-auditing.md).

If you want the wire-level details — event schemas, what each field means, how KMES delivers events to consumers — read [Events and transport](/peios/security-fundamentals/auditing/events-and-transport.md).

---

# Audit ACEs

_Peios / Peios Security Fundamentals / Auditing_

> SYSTEM_AUDIT ACEs fire once per access check; SYSTEM_ALARM ACEs audit every operation on the handle. Polarity, conditional variants, and inheritance.

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:

1. Does the caller's identity match the ACE's SID? If not, the ACE does not apply.
2. Does the requested access overlap the ACE's mask? If not, the ACE does not apply.
3. If the access succeeded and the ACE has `SUCCESSFUL_ACCESS_ACE_FLAG` set, emit an event.
4. If the access failed and the ACE has `FAILED_ACCESS_ACE_FLAG` set, emit an event.
5. 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](/peios/security-fundamentals/auditing/policy-forced-auditing.md)). 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:

1. Computes a continuous-audit mask — the union of all matching alarm ACEs' masks.
2. Returns the mask to the caller as part of the access check's output.
3. 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](/peios/security-fundamentals/security-descriptors/acls-and-aces.md). 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](/peios/security-fundamentals/central-access-policies/overview.md)). 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](/peios/security-fundamentals/auditing/policy-forced-auditing.md).

For what the fired events contain and how they reach userspace, read [Events and transport](/peios/security-fundamentals/auditing/events-and-transport.md).

For where audit ACEs live and who may read or write them, read [The SACL](/peios/security-fundamentals/security-descriptors/the-sacl.md).

---

# Policy-forced auditing

_Peios / Peios Security Fundamentals / Auditing_

> Privilege-use audit and the token's audit_policy bitmask fire events without any SACL ACE. When each fires and how they compose with SACL-driven audit.

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:

1. Step 4: SeBackup grants read-category bits via the privilege.
2. Step 8: DACL walk grants nothing additional.
3. Step 11: confinement intersection strips the privilege-granted bits (confinement does not preserve privilege grants).
4. 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](/peios/security-fundamentals/auditing/events-and-transport.md).

## 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_SUCCESS` is 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_FAILURE` set 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_policy` has the corresponding bit set. A token with `audit_policy = 0` produces 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](/peios/security-fundamentals/logon-sessions/lifecycle.md)) 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](/peios/security-fundamentals/auditing/events-and-transport.md).

For the privilege model behind privilege-use audit, read [Privileges](/peios/security-fundamentals/privileges/overview.md).

---

# Events and transport

_Peios / Peios Security Fundamentals / Auditing_

> The audit event schemas and the KMES transport that carries them — access-audit, continuous-audit, privilege-use, and logon-session-destroyed.

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](/peios/using-peios/wire-formats-reference/overview.md); 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_access` is what *this operation* asks for. A `read` on a file requires read; an `mmap` requires execute or write depending on mode.
- `matched_access` is the subset of `requested_access` that 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_access` is 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:

1. The kernel produces an event (e.g. an audit fires during step 14).
2. The kernel writes the event into each subscriber's KMES buffer.
3. Userspace consumers read from their KMES buffers when they are ready.
4. 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](/peios/advanced-peios/pspk/kmes-event-stream/scope.md), 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-audit` for compliance, just `privilege-use` for 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-audit` with 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](/peios/security-fundamentals/auditing/overview.md) — the model these events come from.
- [Audit ACEs](/peios/security-fundamentals/auditing/audit-aces.md) — the SACL mechanisms behind access-audit and continuous-audit events.
- [Policy-forced auditing](/peios/security-fundamentals/auditing/policy-forced-auditing.md) — the token policy behind privilege-use and forced object-access events.
- [Wire formats reference](/peios/using-peios/wire-formats-reference/overview.md) — byte-level encoding of the SIDs and ACEs carried in events.

---

# Inspecting security state

_Peios / Peios Security Fundamentals / Inspecting security state_

> Read-only surfaces for live KACS state — /proc token files, the securityfs listings, and the KACS_IOC_QUERY ioctl — plus the access rules for reading them.

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>/token` is 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_INFORMATION` on 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>/token` requires `PROCESS_QUERY_INFORMATION` on the target process **plus** PIP dominance.
- `/proc/<pid>/task/<tid>/token` requires the same.
- Token fd from `kacs_open_process_token` requires the same plus the appropriate token rights.

For **session state**:

- `/sys/kernel/security/kacs/sessions` requires `BUILTIN\Administrators` or `SYSTEM` (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_token` or querying through the process-token's interface — always.
- Read another process's PSB requires `PROCESS_QUERY_INFORMATION` plus 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 `/proc` or `/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`](/peios/security-fundamentals/tokens/token-command.md) 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 class `TokenUser` to 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 `TokenStatistics` to get the `auth_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](/peios/security-fundamentals/inspecting/tokens.md).

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](/peios/security-fundamentals/inspecting/sessions.md).

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](/peios/security-fundamentals/inspecting/processes.md).

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](/peios/security-fundamentals/inspecting/the-event-stream.md). 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](/peios/security-fundamentals/access-decisions/debugging-a-denial.md) is the right page.

---

# Inspecting tokens

_Peios / Peios Security Fundamentals / Inspecting security state_

> Reading a token with KACS_IOC_QUERY on a token fd — obtaining fds, the query mechanics, the two-call pattern for variable-length data, and the class catalog.

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:

1. Validates the class against the catalog. Unknown classes return `-EINVAL`.
2. Checks that the fd grants `TOKEN_QUERY`. If not, returns `-EACCES`.
3. Computes the size the response needs.
4. If `buf_ptr` is zero or `buf_len` is zero — this is a **size query** — writes the required size to `buf_len` and returns 0.
5. If `buf_ptr` is non-zero but `buf_len` is smaller than required, returns `-ERANGE` with the required size still written to `buf_len`.
6. Otherwise writes the response to the buffer and returns 0.

The "two-call pattern" — size query then fetch — is the standard way to handle variable-length output:

1. Call once with `buf_ptr = NULL` (or `buf_len = 0`). The kernel writes the required size into `buf_len` and returns 0.
2. Allocate a buffer of the indicated size.
3. Call again with `buf_ptr` set to the buffer and `buf_len` set to its size. The kernel writes the response.

For classes with a fixed-size response, a single call with a buffer of the known size works in one go. The two-call pattern is needed only for classes whose response size depends on the token's contents (the groups class, the restricted-SIDs class, the default-DACL class, the claims classes).

The ioctl is idempotent — multiple queries for the same class produce the same result as long as the token has not been modified. Tokens carry a `modified_id` counter that increments on adjustment; if a query is part of a pipeline that depends on consistency across multiple queries, the `modified_id` can be queried first to detect mid-pipeline changes.

## 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](/peios/using-peios/wire-formats-reference/overview.md); 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/*/token` to 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_QUERY` lets 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`](/peios/security-fundamentals/tokens/token-command.md) 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](/peios/security-fundamentals/inspecting/overview.md) — the topic overview and the shared access rules.
- [The token command](/peios/security-fundamentals/tokens/token-command.md) — the shell wrapper around this ioctl.
- [Tokens](/peios/security-fundamentals/tokens/overview.md) — what the queried fields mean.
- [Wire formats reference](/peios/using-peios/wire-formats-reference/overview.md) — byte-level payload formats for each query class.

---

# Inspecting sessions

_Peios / Peios Security Fundamentals / Inspecting security state_

> Active logon sessions are listed at /sys/kernel/security/kacs/sessions. The listing format, the token-to-session lookup pattern, and lifecycle tracking.

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](/peios/security-fundamentals/logon-sessions/logon-types.md). |
| `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.

1. **Open the token.** For a thread, use `/proc/<pid>/task/<tid>/token`. For a process's primary, use `/proc/<pid>/token`. For yourself, use `kacs_open_self_token` or `/sys/kernel/security/kacs/self`.
2. **Query `TokenStatistics`** via `KACS_IOC_QUERY`. The response includes `auth_id` (the session's LUID).
3. **Look up `auth_id`** in `/sys/kernel/security/kacs/sessions` to find the matching `session_id`. The line gives you the session's full details.

This is the standard "which session is this process in" query. The session ID is the key; the listing has the rest.

For programmatic enumeration ("for each running process, which session is it in"):

```
for each pid in /proc/*:
  for each tid in /proc/<pid>/task/*:
    open /proc/<pid>/task/<tid>/token
    query TokenStatistics, get auth_id
    cross-reference with the sessions listing
```

This produces a complete picture of which thread is in which session. The [`logonse`](/peios/security-fundamentals/logon-sessions/logonse-command.md) command uses exactly this pattern — it walks the running processes to render which processes belong to which session.

## Tracking session lifecycle

Sessions are created and destroyed dynamically. The listing always reflects the *current* state; to track changes over time you need to either poll the listing or subscribe to session lifecycle events.

### Session creation

The kernel does not emit a kernel-level event when a session is created — there is no `logon-session-created` event in v0.20 audit. Tracking creations requires either:

- Periodic polling of `/sys/kernel/security/kacs/sessions` and comparing against the previous snapshot.
- Hooking into authd, which is the only thing that creates sessions and could in principle emit a higher-level event. (This is an authd integration concern, not a kernel one.)
- Listening for the audit events that fire on successful authentications — these are emitted by authd and consumed via KMES.

For most monitoring purposes, the audit stream from authd is the right source. The kernel-level sessions listing tells you "what is right now"; the audit stream tells you "what happened recently".

### Session destruction

The kernel **does** emit a `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](/peios/security-fundamentals/auditing/events-and-transport.md). 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_id` matches.
- Impersonation tokens currently installed on threads whose `auth_id` matches.
- Token fds open against tokens with that `auth_id`.

Counting these from the outside is awkward — there is no "tokens per session" query. The standard way is to walk `/proc/*/token` and `/proc/*/task/*/token`, query `TokenStatistics` on each, and count matches. 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.

> [!WARNING]
> Token `expiration` (a field on each token) is set by authd but not enforced by the kernel in v0.20. A token can have an expiration time in the past and still be valid for AccessCheck.

The token's session continues to exist regardless of any token's expiration value.

If a deployment needs strict session timeouts, the enforcement is in userspace. 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](/peios/security-fundamentals/logon-sessions/lifecycle.md): 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](/peios/security-fundamentals/inspecting/processes.md).

For what sessions are, how they are created, and how revocation actually works, read [Session lifecycle](/peios/security-fundamentals/logon-sessions/lifecycle.md).

---

# Inspecting processes

_Peios / Peios Security Fundamentals / Inspecting security state_

> Inspecting a process's PSB (PIP, mitigations) and process SD. Your own state is free; another process needs PROCESS_QUERY_INFORMATION plus PIP dominance.

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](/peios/security-fundamentals/inspecting/tokens.md); 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:

1. **Open the process's primary token** via `kacs_open_self_token` (with the `KACS_REAL_TOKEN` flag if you need the primary specifically, not the impersonation). The returned fd lets you query token state.
2. **Query through the process-related classes** — `TokenSessionId` and related — which return PSB-adjacent state where available.
3. **Read the process SD** via `kacs_get_sd` with 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](/peios/using-peios/kernel-abi-reference/overview.md); 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_INFORMATION`** on the target's process SD.
- **PIP dominance** over the target (the caller's PIP must dominate the target's, per the [two-check rule](/peios/security-fundamentals/process-integrity-protection/the-two-check-rule.md)).

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](/peios/security-fundamentals/security-descriptors/ownership.md)).

For inspecting the SACL specifically — to see audit ACEs, mandatory labels, PIP trust labels, scoped policy references — `ACCESS_SYSTEM_SECURITY` is the right needed, not `READ_CONTROL`. That right is gated by `SeSecurityPrivilege`. So:

- DACL: needs `READ_CONTROL` (typically held by the owner).
- SACL: needs `ACCESS_SYSTEM_SECURITY` (typically held only by administrators with `SeSecurityPrivilege`).
- Owner / primary group SIDs: need `READ_CONTROL`.

A non-privileged caller can read a process's DACL (if granted) but not its SACL. For administrative inspection of the full SD including SACL, `SeSecurityPrivilege` is the lever.

## Reading mitigation flags

The mitigation bitfield 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:

1. **Open the process's primary token** via `/proc/<pid>/token` or `kacs_open_process_token`.
2. **Query `TokenUser`** for the user SID.
3. **Query `TokenStatistics`** for `auth_id`. Cross-reference with `/sys/kernel/security/kacs/sessions` for session details.
4. **Read the PSB** for PIP and mitigations.
5. **Read the process SD** for who can act on this process.

Each step requires the appropriate access, and each fails closed if the caller lacks authority over the target. For self-targeted queries everything succeeds.

For a debugger or monitoring tool, this is the standard "tell me everything about this process" workflow. The pieces are independent (each query is its own ioctl), but they combine to give a complete picture.

## Live vs static state

A process's state changes over time. The current state is what the inspection surfaces return; previous state is not retrievable.

The **changing** parts of a process's state:

- **Threads come and go.** A process's set of threads is dynamic. Re-running per-thread inspection picks up the current set.
- **The thread's effective token may change** (impersonation install/revert). Re-querying gets the current value.
- **The primary token's adjustable fields** (privileges enabled state, groups enabled state, default DACL) can change. The `modified_id` counter on the token tracks how many changes have happened.
- **The process SD can be modified** by anyone with `WRITE_DAC` on the process. New ACEs appear; old ACEs disappear.

The **immutable** parts of a process's state (once set):

- **PIP fields.** Set at exec; never change for the lifetime of the process.
- **Mitigation flags** (including `NO_CHILD`). One-way; can be tightened but never relaxed.
- **Token identity fields** (user_sid, groups[].sid, restricted_sids, logon_sid). Set at token creation; the token can be replaced but never have its identity adjusted.

Knowing which fields are immutable helps with monitoring. A monitor that has already read the PIP fields once does not need to re-read them; they will not change. A monitor watching for privilege state changes needs to poll or subscribe to events — they can change at any time.

## What inspection cannot tell you

A few clarifications:

- **It cannot tell you what access a process has.** Inspection gives you the inputs to AccessCheck (the token, the object's SD); it does not compute the access. To know what a process can do to a specific object, call AccessCheck.
- **It cannot give you a tamper-evident snapshot.** The kernel may modify state between two reads; there is no "atomic snapshot" surface. Tools that need consistency should use the `modified_id` counter to detect changes.
- **It cannot reveal the contents of the target process's memory.** Inspecting the PSB and the process SD shows you the kernel's metadata about the process. To read the process's *memory* you need `PROCESS_VM_READ` on the process SD plus PIP dominance plus a ptrace-like syscall. That is a different topic.
- **It cannot show you removed history.** A token whose privileges were once enabled but have since been removed shows the current state, not the history. The `used` bit on a privilege is a sticky record of "this privilege has been exercised at some point", but specific timestamps are an audit-log concern, not an inspection concern.

The inspection surfaces are for the present moment. For historical questions, the audit log is the right source.

## Where to go next

For inspecting the live flow of audit events rather than current state, read [The event stream](/peios/security-fundamentals/inspecting/the-event-stream.md).

For the identity half of a process's state — obtaining and querying token fds — read [Inspecting tokens](/peios/security-fundamentals/inspecting/tokens.md).

For the dominance rule that gates every cross-process inspection, read [The two-check rule](/peios/security-fundamentals/process-integrity-protection/the-two-check-rule.md).

---

# The event stream

_Peios / Peios Security Fundamentals / Inspecting security state_

> revstrm is a diagnostic probe that attaches directly to the KMES ring buffers and prints every audit event it drains. Options, output format, and caveats.

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](/peios/security-fundamentals/auditing/events-and-transport.md) per-CPU ring buffers and prints every event it drains. It is deliberately oblivious to the rest of the audit pipeline: it knows nothing about eventd, applies no persistence, and makes no attempt at reliable delivery. It is a debugging probe for the transport layer itself.

## What revstrm is (and is not)

`revstrm` is not the normal way to look at audit events. The normal way is to query **eventd**, the userspace audit daemon that subscribes to KMES, persists events, and serves them to consumers. eventd is the durable, deployment-facing surface. `revstrm` sits *underneath* it, tapping the same kernel stream directly.

Two things make it worth having:

- **It is a diagnostic.** When events are not reaching eventd, or you suspect the kernel is not emitting what you expect, `revstrm` lets you see the raw KMES stream with no daemon in the path. It is the "is the wire live?" probe for the audit pipeline.
- **It is the reference consumer.** `revstrm` exercises the full KMES consumption protocol of [PSPK §2.4](/peios/advanced-peios/pspk/kmes-event-stream/consuming.md) — 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](#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 `sid` are rendered as canonical string SIDs (`S-1-5-18`); keys ending in `sids` render as an array of them.
- Keys ending in `access` are 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](/peios/security-fundamentals/auditing/events-and-transport.md). `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), `revstrm` runs until interrupted or until stdout closes. A downstream `head` (or any reader) closing the pipe shuts `revstrm` down cleanly — it treats the broken pipe as "nothing left to print to" and exits, rather than being killed by `SIGPIPE`. If every per-CPU ring hits a fatal error, all drain threads exit and the process ends.
- In **snapshot** mode (`--snapshot`), it drains what is currently buffered across all rings and exits immediately.

## When to use it

Use `revstrm` when you are debugging the audit *pipeline* — "are events being emitted at all?", "is eventd's problem upstream or downstream of KMES?", "what exactly is the kernel putting on the wire?". Use eventd (or whatever consumes it in your deployment) for everything else: durable audit, historical queries, and any consumption that must not lose events.

## See also

- [Inspecting security state](/peios/security-fundamentals/inspecting/overview.md) — the state-inspection counterparts: tokens, sessions, and processes.
- [Events and transport](/peios/security-fundamentals/auditing/events-and-transport.md) — the schemas of the events revstrm prints.
- [Auditing](/peios/security-fundamentals/auditing/overview.md) — where the events come from.

---

# Boot and trust establishment

_Peios / Peios Security Fundamentals / Boot and trust establishment_

> How a freshly-started kernel becomes a running system with real identities — bootstrap tokens, the initramfs, peinit at PID 1, and the authd handoff.

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":

1. Before the kernel finishes init, nothing works. The kernel is itself.
2. 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.
3. After prelude has run the initramfs, the system's real root filesystem is mounted. prelude switches `/` to it and execs the real init.
4. 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.
5. 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.
6. 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.
7. 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](/peios/security-fundamentals/boot-and-trust-establishment/bootstrap-tokens.md).

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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md).

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](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md).

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](/peios/security-fundamentals/boot-and-trust-establishment/peinit-pid-1.md).

If you want the authd transition — what authd does at startup, the CAAP cache population, the SYSTEM-everywhere-handoff — read [authd handoff](/peios/security-fundamentals/boot-and-trust-establishment/authd-handoff.md).

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](/peios/security-fundamentals/boot-and-trust-establishment/kernel-invariants.md).

The boot *artifacts* themselves are built by two command-line tools, the two halves of Peios' Dynamic Boot system. [mkirf](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md) compiles the `/boot/initramfs/` source tree into the deterministic initramfs image; [mkuki](/peios/security-fundamentals/boot-and-trust-establishment/mkuki.md) 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_

> Before userspace exists the kernel constructs two tokens — authority-maximal SYSTEM and identity-minimal Anonymous — that bootstrap the identity model.

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](/peios/security-fundamentals/impersonation/impersonation-levels.md)).

| 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 `source` field, normally set to whatever component called `kacs_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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md)). 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\Administrators` group 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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md).

For the moment real identities start replacing SYSTEM, read [authd handoff](/peios/security-fundamentals/boot-and-trust-establishment/authd-handoff.md).

For what a token is in general — types, fields, lifecycle — read [Tokens](/peios/security-fundamentals/tokens/overview.md).

---

# The initramfs stage

_Peios / Peios Security Fundamentals / Boot and trust establishment_

> Between kernel init and peinit, prelude — the initramfs PID 1 — prepares the environment, runs the boot hooks that mount the real root, and hands off.

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](/peios/security-fundamentals/boot-and-trust-establishment/bootstrap-tokens.md) covers what the kernel constructs before any userspace exists; [peinit at PID 1](/peios/security-fundamentals/boot-and-trust-establishment/peinit-pid-1.md) 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](/peios/security-fundamentals/boot-and-trust-establishment/bootstrap-tokens.md) describes the SYSTEM token being handed to. From the moment prelude starts, the "everything is SYSTEM" phase of boot (covered in the [overview](/peios/security-fundamentals/boot-and-trust-establishment/overview.md)) 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](/peios/security-fundamentals/boot-and-trust-establishment/peinit-pid-1.md): 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](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md).

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](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md) 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](/peios/security-fundamentals/boot-and-trust-establishment/mkuki.md) 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:

1. **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.
2. **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 `/bin` view.
3. **Run the hooks.** prelude creates an empty `/mnt/rootfs` directory — 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 packaged `mount-rootfs-stratafs-base.sh` hook (from the `fsbase` package) 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](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md)).
4. **Verify the real root.** After the hooks have run, prelude checks that `/mnt/rootfs` actually 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.
5. **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 while `rd.shell` is 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. Without `rd.shell`, a failure halts immediately.

- **`rd.break`** — stop *before any hook runs*. Bare `rd.break` pauses 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](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md), so `rd.break=mount-root.sh` breaks 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](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md) 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](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md) 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 `exec`s 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](/peios/security-fundamentals/boot-and-trust-establishment/bootstrap-tokens.md): 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/rootfs` mount 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](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md).
- **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](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md).

For the init system prelude hands the machine to, read [peinit at PID 1](/peios/security-fundamentals/boot-and-trust-establishment/peinit-pid-1.md).

For the tool that compiles `/boot/initramfs/` into the image, read [mkirf](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md).

---

# Boot hooks

_Peios / Peios Security Fundamentals / Boot and trust establishment_

> Boot hooks do the deployment-specific work of early boot. Where they live, the metadata block, the four ordering keys, and the exit-code protocol.

A **boot hook** is a shell script that runs inside the initramfs, before the real root is mounted — during the [initramfs stage](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md). 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 a `peios-luks` package, 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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md) 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.

```sh
#!/usr/bin/sh
# /// hook
# contributes = ["rootfs-ready"]
# ///

# ... the hook's actual work follows ...
cryptsetup open /dev/sda2 root
```

The rules of the block are small and exact:

- It **opens** with a line that is exactly `# /// hook` and **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-ready` is 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 `requires` or by `after`) runs after **every** hook that **supplies** it (by `provides` or by `contributes`). 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 `requires` a capability that *nothing* installed supplies. Rather than build an initramfs that is guaranteed to fail at boot, mkirf reports the missing capability. An `after` on 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 `75` immediately. 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](/peios/security-fundamentals/boot-and-trust-establishment/bootstrap-tokens.md)), 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:

```sh
#!/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.
[ -b /dev/mapper/root ] || exit 75

mount -t ext4 /dev/mapper/root /mnt/rootfs || exit 1
```

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:

```sh
#!/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.
[ -e /dev/sda2 ] || exit 69

cryptsetup open /dev/sda2 root || exit 1
```

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](/peios/security-fundamentals/boot-and-trust-establishment/peinit-pid-1.md)'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`/`requires` declarations do that.

## Where to go next

For the stage that runs the hooks, read [The initramfs stage](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md).

For the build that validates hooks and resolves their order, read [mkirf](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md).

For what takes over once a hook has mounted the real root, read [peinit at PID 1](/peios/security-fundamentals/boot-and-trust-establishment/peinit-pid-1.md).

---

# peinit at PID 1

_Peios / Peios Security Fundamentals / Boot and trust establishment_

> Why peinit is PID 1 — TCB-signed, holding the SYSTEM token — and the fork-install-exec pattern that launches every service with the right identity.

**peinit** is the init system of a running Peios system — the process that takes over once the [initramfs stage](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md) 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.

> [!NOTE]
> This page is about peinit's place in the **boot and trust chain** — why it is PID 1, the identity it holds, and how it hands off to the rest of userspace. The full **service-manager model** — the service definition schema, the state machine, dependencies, supervision and restart policy, timers, jobs and operations, and the control interface — has its own topic. Start at [peinit](/peios/using-peios/services-and-jobs/overview.md).

## 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](/peios/security-fundamentals/process-integrity-protection/pip-in-practice.md). 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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md); 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:

1. **Assert PID 1.** peinit refuses to run as anything else.
2. **Probe that the real root is writable**, and ensure the virtual filesystems (`/proc`, `/sys`, `/dev`, …) are mounted.
3. **Restore the random seed, ensure the machine-id, set the clock from the RTC.**
4. **Start registryd** and probe the registry schema. The registry is the source of all subsequent configuration, so its daemon comes up before anything else.
5. **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**:

1. 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](/peios/using-peios/services-and-jobs/identity-and-privileges.md)).
2. peinit forks; the child inherits a SYSTEM-derived token.
3. peinit installs the service-specific token on the child via `KACS_IOC_INSTALL`.
4. The child execs the service binary. The kernel verifies the signature and sets the PIP fields.

> [!NOTE]
> Four jobs are sometimes attributed to peinit that it does not do. It does not populate the machine-wide TLP cache from the registry, and it does not apply per-service mitigation flags via `kacs_set_psb` between fork and exec. Mount-policy application (`kacs_set_mount_policy`) is likewise not a peinit responsibility, and neither is boot-time CAAP distribution — that one belongs to authd, and is currently deferred.

### 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:

1. **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.
2. **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.
3. **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_token` is 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](/peios/using-peios/services-and-jobs/overview.md).

## 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_

> When authd comes online the system moves from SYSTEM-everywhere to real identities — token minting, session management, and CAAP distribution.

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-destroyed` event.
- **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:

1. Client connects to authd's socket and presents credentials.
2. authd verifies the credentials against the directory.
3. authd resolves the principal's full identity (groups, privileges, claims) from the directory.
4. authd creates a logon session via `kacs_create_session`.
5. authd calls `kacs_create_token` to mint the token, passing the wire-format specification with everything resolved.
6. 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-destroyed` event 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/*/token` to find tokens with the offending `auth_id`, identifies the holding processes, and signals them to exit. This is the userspace-coordinated revocation pattern documented in [Session lifecycle](/peios/security-fundamentals/logon-sessions/lifecycle.md).

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](/peios/security-fundamentals/boot-and-trust-establishment/peinit-pid-1.md).

For the SYSTEM-everywhere state the handoff replaces, read [Bootstrap tokens](/peios/security-fundamentals/boot-and-trust-establishment/bootstrap-tokens.md).

For the recovery policy that applies until the CAAP cache is populated, read [Distribution and recovery](/peios/security-fundamentals/central-access-policies/distribution-and-recovery.md).

---

# Kernel invariants

_Peios / Peios Security Fundamentals / Boot and trust establishment_

> The kernel-level invariants the boot chain depends on — the LSM stack rule, forced module signing, strict /dev/mem — and what fails without them.

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
```

- **`commoncap`** is implicit (Linux always includes it; not listed in `CONFIG_LSM` explicitly).
- **`landlock`** is permitted — it's a process-local sandboxing LSM that processes opt into; it doesn't conflict with PKM.
- **`lockdown`** is permitted — it gates specific kernel-debugging interfaces; orthogonal to PKM.
- **`yama`** is permitted — it adds ptrace restrictions; complements PKM's ptrace gating.
- **`integrity`** is permitted — Linux's IMA/EVM mechanisms can run alongside PKM for the use cases they cover.
- **`pkm`** is 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/mem` is readable only for the kernel's defined I/O regions (memory-mapped I/O, the VGA framebuffer, etc.).
- General RAM is not readable through `/dev/mem`.
- `/dev/kmem` is unavailable.

This closes the physical-memory bypass for PIP. Combined with `CONFIG_MODULE_SIG_FORCE`, the two flags eliminate the kernel-level escapes from PIP that don't require an actual kernel compromise.

The flag is build-time; the kernel image either has it or doesn't. A kernel without it is not a valid Peios kernel for security purposes.

## 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:

- **`peinit` is signed at TCB level.** If the binary peinit is exec'd from is not signed at TCB, the kernel's verification at exec sets `pip_type = None`. peinit then runs unprotected, and any other process can interfere with it. This is operationally untenable; a properly-built Peios image has peinit signed.
- **`authd`, `loregd`, `eventd`, `lpsd` are signed at TCB level.** Same reasoning. These are the TCB daemons; they need PIP protection to function in their role.
- **No kernel modules are loaded other than signed ones.** Per `CONFIG_MODULE_SIG_FORCE`.

These are not enforced at boot in the sense of "the boot will fail" if violated — they are operational expectations. A Peios system where peinit is unsigned will boot, run, and "work" in a superficial sense; it just won't have the security properties Peios documents.

## The invariant verification

The kernel's init code does several checks at start:

1. **Verify PKM is in the LSM stack** and no incompatible LSM is present. PKM refuses to activate if violated.
2. **Verify `CONFIG_MODULE_SIG_FORCE` is set.** (This is a build-time flag; at runtime the kernel just behaves consistently with the build.)
3. **Verify `CONFIG_STRICT_DEVMEM` is set.** (Same; build-time.)
4. **Construct the SYSTEM and Anonymous tokens.** As covered in [Bootstrap tokens](/peios/security-fundamentals/boot-and-trust-establishment/bootstrap-tokens.md).
5. **Attach the SYSTEM token to init.** Init becomes the first process holding it.
6. **Start userspace.** init execs the configured first program — **prelude**, the initramfs PID 1, which runs the initramfs and then execs the real init (peinit, in a normal Peios image). See [The initramfs stage](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md).

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 with `SeLoadDriverPrivilege` can load arbitrary kernel modules and bypass everything. PIP is no longer meaningful.
- **`CONFIG_STRICT_DEVMEM=n`.** `/dev/mem` can read physical memory. Any process with `READ_CONTROL` on `/dev/mem` (or whose DACL grants it) can bypass PIP by reading memory directly.

Each failure mode is a security regression. The boot chain depends on the invariants holding. A deployment that needs to be sure its security model is operating correctly verifies these as part of the boot validation — typically via a post-boot tool that reads `/proc/sys/kernel/lsm`, checks build-time flags via the kernel config, etc.

For most Peios deployments built from the official kernel image, the invariants hold by construction. The verification is mostly relevant for custom kernels built from source — anyone modifying the kernel image needs to know which flags they cannot change.

## 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](/peios/security-fundamentals/boot-and-trust-establishment/bootstrap-tokens.md).

For the signing scheme module signing shares its keys with, read [Binary signing](/peios/security-fundamentals/binary-signing/overview.md).

For the protection model these invariants defend, read [Process integrity protection](/peios/security-fundamentals/process-integrity-protection/overview.md).

---

# mkirf

_Peios / Peios Security Fundamentals / Boot and trust establishment_

> Compile the /boot/initramfs/ source tree into a deterministic cpio initramfs image, resolving hook order and validating the layout. Has a watch mode.

`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/`](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md) — 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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md); for how hooks declare their order, see [Boot hooks](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md). `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:

1. **Validate the layout.** `<src-dir>` must be a directory, must carry an executable `init` (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.seq` or `system/prelude/hooks.seq.<n>` — which `mkirf` generates. A missing or non-executable `init`, or a stray sequence file, stops the build.
2. **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 in `LC_ALL=C` byte order, which places every directory before its descendants — the order the kernel's unpacker requires.
3. **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. `mkirf` parses each hook's `# /// hook` metadata 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 missing `hooks/` 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 `mkirf` writes every version it can express faithfully — currently `hooks.seq.1` (the flat resolved order) and `hooks.seq.2` (that order plus each hook's declarations). `mkirf` ships in `peiosutils` and 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, alongside `system/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](#validation-and-errors) below.
4. **Write atomically.** The archive is written to a sibling temp file and `rename`d into place, so a failed run never leaves a half-written image behind. On success `mkirf` prints 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`, symlinks `0777`, FIFOs and device nodes `0644`, regular files `0755` if executable and `0644` otherwise.
- **The gzip member** carries mtime 0 and no embedded filename (the `gzip -n` equivalent), 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; no `init`, or `init` is 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 `# /// hook` metadata block (unclosed, a non-comment line inside it, a duplicate or unknown key, a badly-formed array), a dependency **cycle**, a `requires` that **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). An `after` naming a capability nothing supplies is *not* an error — that is what distinguishes it from `requires`. (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, and `mkirf` prints 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, `--watch` with `<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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md) — what `mkirf`'s output is for.
- [Boot hooks](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md) — the `# /// hook` metadata block and how order is resolved.
- [mkuki](/peios/security-fundamentals/boot-and-trust-establishment/mkuki.md) — 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_

> Build a UEFI unified kernel image — kernel, initramfs, and command line appended as PE sections onto an EFI stub. Has a watch mode.

`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`](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md) 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 `rename`s 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.

> [!NOTE]
> `mkuki` does **not** sign the image. Producing the unified binary and signing it for Secure Boot are separate steps; `mkuki` has no signing options. Because a UKI bundles the kernel, initramfs, and command line into one PE image, that image is what a later Secure Boot milestone signs and the firmware verifies as a whole — but the signing itself is out of `mkuki`'s scope.

## 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](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md), 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](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md) — builds the `.initrd` payload `mkuki` wraps.
- [The initramfs stage](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md) — what the bundled initramfs does once firmware boots the UKI.

---

# Threads and processes

_Peios / Using Peios / Threads and processes_

> A process is a running program; a thread is a single line of execution inside it. Every action the system takes happens on some thread.

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](/peios/security-fundamentals/tokens/overview.md)). 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](/peios/using-peios/threads-and-processes/the-process-security-block.md)
(PSB), which holds its security-related settings.

## Where to start

Continue with [The process and thread model](/peios/using-peios/threads-and-processes/the-process-and-thread-model.md)
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](/peios/using-peios/threads-and-processes/creating-processes.md) — how a
  process starts another, and what the new one begins with.
- [Process lifecycle](/peios/using-peios/threads-and-processes/process-lifecycle.md) — how a
  process ends, and how the system cleans up after it.
- [Process relationships and job control](/peios/using-peios/threads-and-processes/relationships-and-job-control.md)
  — the process tree, process groups, and sessions (distinct from the logon
  sessions in [Logon sessions](/peios/security-fundamentals/logon-sessions/overview.md)).

---

# The process and thread model

_Peios / Using Peios / Threads and processes_

> A thread is the basic unit of work; a process is the boundary around its threads, holding the memory, open resources, and identity they share.

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](/peios/using-peios/threads-and-processes/creating-processes.md).

## 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](/peios/security-fundamentals/impersonation/overview.md); 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](/peios/using-peios/threads-and-processes/thread-operations-reference.md).

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](/peios/using-peios/threads-and-processes/creating-processes.md).

---

# Creating processes

_Peios / Using Peios / Threads and processes_

> How processes are created — fork, exec, spawn, and clone — what a new process starts with, and how to hold a reliable handle to one with a pidfd.

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](/peios/security-fundamentals/tokens/lifecycle.md).)

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](/peios/using-peios/threads-and-processes/the-process-and-thread-model.md).
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](/peios/using-peios/threads-and-processes/process-creation-reference.md).

## 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](/peios/using-peios/threads-and-processes/process-lifecycle.md).

---

# Process lifecycle

_Peios / Using Peios / Threads and processes_

> How a process ends, how its result is collected, what happens when its parent ends first, and how the system cleans up after it.

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](/peios/using-peios/threads-and-processes/creating-processes.md) 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](/peios/using-peios/threads-and-processes/process-lifecycle-reference.md).

## 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](/peios/security-fundamentals/tokens/lifecycle.md).

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](/peios/using-peios/threads-and-processes/relationships-and-job-control.md).

---

# Process relationships and job control

_Peios / Using Peios / Threads and processes_

> The two ways processes relate — descent, forming a family tree rooted at PID 1, and grouping into process groups and sessions for job control.

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](/peios/using-peios/threads-and-processes/process-lifecycle.md).

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](/peios/security-fundamentals/logon-sessions/overview.md).

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](/peios/using-peios/threads-and-processes/the-process-security-block.md).

---

# The Process Security Block

_Peios / Using Peios / Threads and processes_

> The Process Security Block (PSB) records what a process is — its program's trust, its hardening, and who may operate on it — as distinct from who it acts as.

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](/peios/using-peios/threads-and-processes/the-process-and-thread-model.md) — 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](/peios/security-fundamentals/process-integrity-protection/overview.md).
- **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](/peios/security-fundamentals/process-mitigations/overview.md).
- **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](/peios/security-fundamentals/process-integrity-protection/overview.md), for the
trust label that governs which processes may interfere with which, and
[Process mitigations](/peios/security-fundamentals/process-mitigations/overview.md), for the self-hardening
flags and how they are applied.

---

# Process creation reference

_Peios / Using Peios / Threads and processes_

> The precise contract for fork, vfork, clone and clone3, the clone sharing flags, the exec family, posix_spawn, and the pidfd calls.

This is the detailed counterpart to
[Creating processes](/peios/using-peios/threads-and-processes/creating-processes.md). 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](/peios/security-fundamentals/tokens/lifecycle.md))
- the parent's mitigations and protection, carried on its
  [PSB](/peios/using-peios/threads-and-processes/the-process-security-block.md)

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) or `MADV_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 `fcntl` locks — distinct from the OFD and
  `flock` locks above, which are shared through the inherited descriptors)
- timers (`setitimer`, `alarm`, `timer_create`)
- outstanding asynchronous I/O
- directory-change notifications (`dnotify`)
- the `PR_SET_PDEATHSIG` parent-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 `exec`s 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](/peios/advanced-peios/linux-compatibility/dac-neutralization-and-capabilities.md).

## 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 `exec`s 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_CGROUP` places 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](/peios/security-fundamentals/tokens/lifecycle.md) 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](/peios/advanced-peios/linux-compatibility/setuid-and-uid0.md).

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](/peios/using-peios/threads-and-processes/creating-processes.md). `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](/peios/using-peios/threads-and-processes/the-process-security-block.md).

## See also

- [Creating processes](/peios/using-peios/threads-and-processes/creating-processes.md) — the conceptual counterpart to this page.
- [Process lifecycle reference](/peios/using-peios/threads-and-processes/process-lifecycle-reference.md) — ending a process and collecting its result.
- [Thread operations reference](/peios/using-peios/threads-and-processes/thread-operations-reference.md) — per-thread calls: TIDs, exit notification, TLS.

---

# Process lifecycle reference

_Peios / Using Peios / Threads and processes_

> The precise contract for ending a process and collecting its result — exit, _exit and exit_group, the exit-status encoding, and the wait family.

This is the detailed counterpart to
[Process lifecycle](/peios/using-peios/threads-and-processes/process-lifecycle.md). 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](/peios/using-peios/threads-and-processes/process-lifecycle.md) — 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](/peios/using-peios/threads-and-processes/creating-processes.md) — 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](/peios/using-peios/threads-and-processes/process-lifecycle.md) — the conceptual counterpart to this page.
- [Process creation reference](/peios/using-peios/threads-and-processes/process-creation-reference.md) — fork, clone, exec, and the pidfd calls.

---

# Thread operations reference

_Peios / Using Peios / Threads and processes_

> The per-thread operations a program uses directly — reading a thread's ID, the exit-notification mechanism behind thread joins, and thread-local storage.

This is the thread-level companion to
[the process and thread model](/peios/using-peios/threads-and-processes/the-process-and-thread-model.md)
— 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](/peios/using-peios/threads-and-processes/process-creation-reference.md) — 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](/peios/using-peios/threads-and-processes/the-process-and-thread-model.md) — the conceptual counterpart to this page.
- [Process creation reference](/peios/using-peios/threads-and-processes/process-creation-reference.md) — the clone flags that create threads and arrange TLS and clear-tid addresses.

---

# peinit

_Peios / Using Peios / Services & jobs_

> peinit is PID 1 — the single service manager. Services, jobs, and operations; identity built in; and how it differs from the init systems you know.

**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](/peios/security-fundamentals/tokens/overview.md) that decides what it can reach, and each service is itself a securable object with a [security descriptor](/peios/security-fundamentals/security-descriptors/overview.md) 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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md)) 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](/peios/security-fundamentals/auditing/overview.md) as they complete, which is how the history of a service is reconstructed after the fact. They get their own page: [Jobs and operations](/peios/using-peios/services-and-jobs/jobs-and-operations.md).

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](/peios/security-fundamentals/tokens/overview.md). 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](/peios/using-peios/services-and-jobs/identity-and-privileges.md).

## 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](/peios/using-peios/registry-concepts/overview.md) 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](/peios/security-fundamentals/auditing/overview.md)'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](/peios/using-peios/services-and-jobs/output-and-logging.md).

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](/peios/using-peios/services-and-jobs/service-types.md); 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](/peios/using-peios/registry-concepts/watches.md). Supervision never waits on the registry being available. This is why a configuration change does not always take effect immediately — see [Defining a service](/peios/using-peios/services-and-jobs/defining-a-service.md).
- **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](/peios/security-fundamentals/boot-and-trust-establishment/overview.md): a compromise of peinit is a compromise of the whole machine. It runs as [SYSTEM](/peios/security-fundamentals/identity/well-known-principals.md) 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](/peios/security-fundamentals/access-decisions/overview.md). |
| **LCS / the registry** | Service definitions, boot configuration, and its own parameters. See [The registry](/peios/using-peios/registry-concepts/overview.md). |
| **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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md).) |
| **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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md).

## Where to start

If you are configuring a system, start with **[Defining a service](/peios/using-peios/services-and-jobs/defining-a-service.md)** — 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](/peios/using-peios/services-and-jobs/controlling-services.md)** for the command set and **[The service lifecycle](/peios/using-peios/services-and-jobs/the-service-lifecycle.md)** to read what a status actually tells you.

If you care about *what runs as whom*, read **[Service identity and privileges](/peios/using-peios/services-and-jobs/identity-and-privileges.md)** and **[Who can manage a service](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md)** — the two independent halves of the security model.

If you are investigating boot behaviour, **[Boot and boot modes](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md)** covers Full, Safe, and Recovery modes and the escalation between them.

And when something is wrong, **[Troubleshooting peinit](/peios/using-peios/services-and-jobs/troubleshooting.md)** works backward from the symptom.

---

# Defining a service

_Peios / Using Peios / Services & jobs_

> A service is a registry key under Machine\System\Services\, made of typed values. The schema, and the mutability class that decides when a change lands.

A service is defined by a single key in the [registry](/peios/using-peios/registry-concepts/overview.md), under `Machine\System\Services\<name>`. The key's name *is* the service name, and the typed [values](/peios/using-peios/registry-concepts/keys-values-and-types.md) 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](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md) descriptor, and in the [per-service SID](/peios/using-peios/services-and-jobs/identity-and-privileges.md) 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](/peios/using-peios/services-and-jobs/registry-key-reference.md).

**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](/peios/using-peios/services-and-jobs/execution-environment.md). |
| `LimitNOFILE`, `LimitCORE` | — | `RLIMIT_NOFILE` / `RLIMIT_CORE`. |

**What kind of service** — type and readiness. See [Simple and Oneshot services](/peios/using-peios/services-and-jobs/service-types.md).

| 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](/peios/using-peios/services-and-jobs/triggers-and-timers.md).

| 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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md). |
| `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](/peios/using-peios/services-and-jobs/identity-and-privileges.md).

| 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](/peios/using-peios/services-and-jobs/dependencies.md).

| 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](/peios/using-peios/services-and-jobs/supervision.md).

| 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](/peios/using-peios/services-and-jobs/execution-environment.md) and [The service lifecycle](/peios/using-peios/services-and-jobs/the-service-lifecycle.md).

| 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](/peios/using-peios/registry-concepts/watches.md): 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.

> [!NOTE]
> `reload-config` is the explicit, atomic way to make peinit re-read *everything*. It builds and validates a complete new graph snapshot and swaps it in only if validation passes; running services are not disturbed. It is covered with the rest of the command set in [Controlling services](/peios/using-peios/services-and-jobs/controlling-services.md).

## 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](/peios/using-peios/services-and-jobs/jobs-and-operations.md), 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.

> [!IMPORTANT]
> Removing a definition is not how you stop a service. It stops *future management*, not work in progress. To actually stop a running service, `stop` it. To prevent it from auto-starting, set `Disabled=1` (see [Triggers and timers](/peios/using-peios/services-and-jobs/triggers-and-timers.md)). To prevent it from being started at all, deny `SERVICE_START` in its [ServiceSecurity](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md) descriptor.

## Where to start

To understand the `Type` and `Readiness` fields and the Simple/Oneshot split, read [Simple and Oneshot services](/peios/using-peios/services-and-jobs/service-types.md).

To understand how a definition becomes a running, supervised process — and what each state in a `status` output means — read [The service lifecycle](/peios/using-peios/services-and-jobs/the-service-lifecycle.md).

For the complete type-and-default catalog of every registry key peinit reads, see the [Registry key reference](/peios/using-peios/services-and-jobs/registry-key-reference.md).

---

# Simple and Oneshot services

_Peios / Using Peios / Services & jobs_

> peinit has exactly two service types — long-running Simple and run-to-completion Oneshot — plus the readiness models that decide when each counts as started.

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](/peios/using-peios/services-and-jobs/identity-and-privileges.md), 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](/peios/using-peios/services-and-jobs/dependencies.md) 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](#the-sd-notify-contract). | 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.

> [!WARNING]
> Using `Readiness=Alive` on a service that other services `Requires` is a common configuration smell. peinit will let it boot, but it logs a validation **warning**: Alive readiness gives no guarantee the service is actually serving before its dependents start. If a dependent connects on boot and gets connection-refused, this is usually why. Prefer Notify wherever the service can signal it.

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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) and [Keeping services running](/peios/using-peios/services-and-jobs/supervision.md).

## 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](/peios/using-peios/services-and-jobs/supervision.md) 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:

- **`StartTimeout` covers 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 raise `StartTimeout` accordingly (or send `EXTEND_TIMEOUT_USEC` as it works — see [Keeping services running](/peios/using-peios/services-and-jobs/supervision.md)).
- **`ExecStartPost` runs only on success.** Post-hooks fire after the successful exit. If the Oneshot fails, `ExecStartPost` does 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](/peios/using-peios/services-and-jobs/triggers-and-timers.md); 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](/peios/using-peios/services-and-jobs/output-and-logging.md) 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](/peios/using-peios/services-and-jobs/supervision.md) and [Controlling services](/peios/using-peios/services-and-jobs/controlling-services.md).

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](#no-forking-daemons).

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](/pekit/recipes/packages.md) 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](/peios/using-peios/services-and-jobs/triggers-and-timers.md).

To follow a service from definition to running process and read its states, read [The service lifecycle](/peios/using-peios/services-and-jobs/the-service-lifecycle.md).

To configure restart, health checks, and watchdogs for a Simple service, read [Keeping services running](/peios/using-peios/services-and-jobs/supervision.md).

---

# Triggers and timers

_Peios / Using Peios / Services & jobs_

> Boot and timer triggers, the Disabled flag, and the full OnCalendar grammar — persistent catch-up, jitter, and what happens when the clock jumps.

A **trigger** decides *when* a service starts on its own. A service's triggers are independent of its [type](/peios/using-peios/services-and-jobs/service-types.md): 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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) sequence, in dependency order. |
| **Timer** | `timer:<schedule>` | Start on a schedule. The schedule is a [calendar expression](#calendar-expressions). |

A service with **no** triggers is **demand-only**: it never starts itself, and is brought up only by an explicit [control command](/peios/using-peios/services-and-jobs/controlling-services.md) or because another service [depends](/peios/using-peios/services-and-jobs/dependencies.md) 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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md), 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.

> [!NOTE]
> Reach for this only to keep console output legible. If a service genuinely needs something to be running first, that is a [dependency](/peios/using-peios/services-and-jobs/dependencies.md) — say so with `Requires`. A console login uses both: `boot:settled` for *when*, and `Requires` on its authority for *what must be true*.

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](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md) descriptor |
| Stop a *running* service | The [`stop` command](/peios/using-peios/services-and-jobs/controlling-services.md) |

Enabling and disabling are registry writes performed by admin tooling, not peinit commands. peinit picks up a `Disabled` change through a [registry notification](/peios/using-peios/registry-concepts/watches.md) 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](/peios/using-peios/services-and-jobs/jobs-and-operations.md), 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.

> [!NOTE]
> The timestamp is written *when the timer fires and the start is initiated*, not when the run completes. A service that crashes mid-run is not re-triggered on the next boot — the run was attempted, not missed.

## 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.

> [!CAUTION]
> If a machine boots with a wildly wrong clock (a dead RTC battery) and NTP corrects it only later, the boot-time persistent catch-up decision is made against the wrong time and may fire spuriously or not at all. The *runtime* half self-heals once NTP steps the clock, but the boot-time decision is already made by then. This is a known edge case short of NTP-aware rescheduling — worth knowing if you run timers on hardware with unreliable clocks.

## Where to start

To see how a timer-started run appears as a job and an operation, read [Jobs and operations](/peios/using-peios/services-and-jobs/jobs-and-operations.md).

To understand the states a timer-started service moves through, read [The service lifecycle](/peios/using-peios/services-and-jobs/the-service-lifecycle.md).

For the exact registry keys that store last-run timestamps, see the [Registry key reference](/peios/using-peios/services-and-jobs/registry-key-reference.md).

---

# The service lifecycle

_Peios / Using Peios / Services & jobs_

> The ten service states, the transitions that connect them, and the cause taxonomy that says why a service ended up where it is.

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](/peios/using-peios/services-and-jobs/controlling-services.md) and [Troubleshooting](/peios/using-peios/services-and-jobs/troubleshooting.md), 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](/peios/using-peios/services-and-jobs/execution-environment.md) 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](/peios/using-peios/services-and-jobs/dependencies.md) 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.

```mermaid
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 → Starting` is the retry; `Failed` is reached only when restarts are exhausted or disabled. This is why [`OnFailure`](/peios/using-peios/services-and-jobs/supervision.md) fires once at the end, not on every retry.
- `Starting` can fail *before any process exists* — a parent-side setup error, a failed pre-hook, a condition or assert. The cause records which.
- `Failed → Starting` is how a manual `start` (or a recovery path) revives a dead service; automatic restarts never originate from `Failed`.
- `Stopping` has **two** exits. A clean stop or a shutdown lands in `Inactive`. But a service stopped because it lost a [`Conflicts`](/peios/using-peios/services-and-jobs/dependencies.md) race (`ConflictEviction`) or because its [`BindsTo`](/peios/using-peios/services-and-jobs/dependencies.md) target went away (`BindsToPropagation`) comes to rest in `Failed`, carrying that cause — so an evicted or bound-out service shows as Failed in `status` and needs a `reset` (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](/peios/using-peios/services-and-jobs/service-types.md).

> [!NOTE]
> **A crash while `Reloading` is still a crash.** If a service's main process *dies* while it is re-reading its configuration, peinit treats that as an ordinary `ProcessCrash` and routes it through the [restart policy](/peios/using-peios/services-and-jobs/supervision.md) — `Reloading → Backoff` if a restart is warranted, or `Reloading → Failed` if it is not. This is why you can see a service jump straight from Reloading into Backoff or Failed. It is *distinct* from an `ExecReload` **command** failing: a failed reload command leaves the running process untouched and the service stays `Active`, reporting the failure without changing state.

## 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](/peios/security-fundamentals/auditing/overview.md). 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](/peios/using-peios/services-and-jobs/dependencies.md) 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](/peios/using-peios/services-and-jobs/dependencies.md) 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](/peios/using-peios/services-and-jobs/supervision.md)), whereas the definition/graph causes are **never restarted** — retrying a broken definition cannot help. The distinction is spelled out in [Keeping services running](/peios/using-peios/services-and-jobs/supervision.md).

> [!TIP]
> When a service is `Failed`, the cause is the first thing to read. A `ProcessCrash` or `WatchdogTimeout` points at the service's own code or health; a `DependencyFailure` points at something it needs; a `ValidationError` points at its definition. [Troubleshooting peinit](/peios/using-peios/services-and-jobs/troubleshooting.md) is organised around exactly this lookup.

## 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](/peios/using-peios/services-and-jobs/dependencies.md).

## 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:

1. A service is in **exactly one** state at any moment.
2. Only the transitions peinit defines are valid — there are no others.
3. **Only peinit** changes a service's runtime state. No external process can reach in and set it.
4. A service's *securable identity* (its [ServiceSecurity](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md) descriptor — who can manage it) is independent of its *process identity* (its [token](/peios/using-peios/services-and-jobs/identity-and-privileges.md) — what it can access).
5. Readiness is **per start generation** — a `READY=1` from 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](/peios/using-peios/services-and-jobs/supervision.md).

To understand how dependents block on and are released by these states, read [Dependencies and ordering](/peios/using-peios/services-and-jobs/dependencies.md).

To see which commands are valid in which state, read the command × state matrix in [Controlling services](/peios/using-peios/services-and-jobs/controlling-services.md).

---

# Keeping services running

_Peios / Using Peios / Services & jobs_

> Restart policy with backoff and a budget, active health checks, the watchdog, timeout extension, OnFailure, and the Critical reboot path.

Once a service is [Active](/peios/using-peios/services-and-jobs/the-service-lifecycle.md), 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](#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](/peios/using-peios/services-and-jobs/dependencies.md) 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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) 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.

```mermaid
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"]
```

> [!TIP]
> If a service flaps — restarting endlessly without ever sticking — and never hits its budget, the usual cause is that it briefly reaches Active each time and resets the counter. Either it is recovering long enough to reset (raise `RestartWindow`) or you are looking at a [health-check flap](#health-checks), which has its own guardrail below.

### 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 `Never` or `OnFailure`, the cause is `CleanExit` and the service goes straight to **Inactive**, with no restart-policy consultation at all.
- Under `Always`, the cause is `CleanExitRestart` and 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](/peios/using-peios/services-and-jobs/service-types.md).)

## 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](/peios/using-peios/services-and-jobs/service-types.md) 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](/peios/using-peios/services-and-jobs/identity-and-privileges.md)). |
| `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.

> [!CAUTION]
> Be extremely cautious with health checks on `ErrorControl=Critical` services. A false-positive health-check failure on a Critical service will, after exhausting the restart budget, **reboot the machine** — and a flaky check can do this repeatedly. Critical platform services are better served by a passive [watchdog](#the-watchdog) with a generous timeout: the service itself knows whether it is healthy and simply stops sending keepalives if not. peinit applies the same health-check semantics to Critical services as to any other — there is no special-casing to save you.

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](/peios/using-peios/services-and-jobs/output-and-logging.md) 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](/peios/using-peios/services-and-jobs/service-types.md) `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](/peios/using-peios/services-and-jobs/shutdown.md) 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](/peios/security-fundamentals/auditing/overview.md)'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 the `OnFailure` handler.
- **`ErrorControl=Critical` implies `SafeMode`.** A Critical service is always eligible to start in [Safe mode](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md).
- **Critical services are OOM-protected.** peinit sets their `oom_score_adj` to `-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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md).

> [!WARNING]
> A Critical service that fails the same way on every boot becomes a **reboot loop**, which the boot-attempt counter is designed to break by escalating to Recovery mode after a few attempts. If you see a machine rebooting repeatedly, a crashing Critical service is the first suspect. See [Troubleshooting peinit](/peios/using-peios/services-and-jobs/troubleshooting.md).

## Where to start

To see exactly which states these policies move a service through, read [The service lifecycle](/peios/using-peios/services-and-jobs/the-service-lifecycle.md).

To understand how a failed service propagates to the ones that depend on it, read [Dependencies and ordering](/peios/using-peios/services-and-jobs/dependencies.md).

When a service will not stay up, work backward from the symptom in [Troubleshooting peinit](/peios/using-peios/services-and-jobs/troubleshooting.md).

---

# Dependencies and ordering

_Peios / Using Peios / Services & jobs_

> Requires, Wants, BindsTo, and Conflicts — start order, stop order, failure propagation, graph validation, and parallel start.

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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) before A starts. If B is not running, peinit starts it first (cause `DependencyStart`). If B fails, A is marked Failed with `DependencyFailure` and 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](/peios/using-peios/services-and-jobs/supervision.md) 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](/peios/using-peios/services-and-jobs/triggers-and-timers.md) — 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** (carrying `BindsToPropagation`), *not* Inactive — so a `status` query 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](/peios/using-peios/services-and-jobs/supervision.md). If B never comes back, A stays Failed until you `reset` (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 (cause `ConflictEviction`) before A starts — and vice versa. If the loser will not stop within its `StopTimeout`, [SIGKILL escalation](/peios/using-peios/services-and-jobs/shutdown.md) applies. The evicted loser does **not** land in Inactive: it comes to rest in **Failed** (carrying `ConflictEviction`), so it shows as Failed in `status` and needs a `reset` before 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](#on-demand-starts)'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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) 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](/peios/using-peios/services-and-jobs/service-types.md)). 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](/peios/using-peios/services-and-jobs/supervision.md). 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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) 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.

> [!NOTE]
> Boot order is *emergent*, not hardcoded. The typical sequence — `eudev`, `lpsd`, `authd`, `eventd`, networking, then application and login services — is simply what the standard role definitions' dependencies produce. Change the dependencies and you get a different order. The platform daemons come up first because everything else, directly or transitively, requires them.

## Failure propagation

When a service enters Failed during graph execution, the failure spreads along `Requires` and `BindsTo` edges — and only those:

1. Every service that `Requires` the failed one transitions to Failed with `DependencyFailure`.
2. Every service that merely `Wants` it is **unaffected** and starts normally.
3. Propagation is **transitive**: if A requires B and B requires C, and C fails, then B fails, 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:

1. Collect all transitive `Requires` and `BindsTo` dependencies (and best-effort `Wants`).
2. Validate that sub-graph (cycles, missing targets).
3. Resolve `Conflicts` — stop anything that conflicts.
4. 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](/peios/using-peios/services-and-jobs/jobs-and-operations.md) rather than racing.

## Shutdown reverses the graph

peinit does not need a separate stop-ordering configuration. [Shutdown](/peios/using-peios/services-and-jobs/shutdown.md) 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](/peios/using-peios/services-and-jobs/shutdown.md).

## Where to start

To see the states services move through as they start and stop in order, read [The service lifecycle](/peios/using-peios/services-and-jobs/the-service-lifecycle.md).

To understand how a target's failure interacts with the dependent's own restart policy, read [Keeping services running](/peios/using-peios/services-and-jobs/supervision.md).

To follow how dependency-driven starts appear as operations, read [Jobs and operations](/peios/using-peios/services-and-jobs/jobs-and-operations.md).

---

# Service identity and privileges

_Peios / Using Peios / Services & jobs_

> How a service token is materialised — minted for SYSTEM, requested from authd otherwise — plus the per-service SID and RequiredPrivileges trimming.

Every service process runs under a [KACS token](/peios/security-fundamentals/tokens/overview.md) — 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](#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](/peios/security-fundamentals/boot-and-trust-establishment/authd-handoff.md).** |
| 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-per-service-sid). The minted token is fully independent — the [privilege trimming](#privilege-restriction) 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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md), before authd is available to mint anything.

> [!NOTE]
> There is no allow-list restricting which services *may* be `SYSTEM`. The security boundary is the [registry key descriptor](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md) on `Machine\System\Services\` — an administrator who can create service definitions is already trusted to assign any identity, so a second gate would add nothing. The dangerous case is never *implicit*, though: `SYSTEM` must be written out explicitly; an empty `Identity` defaults to the minimal `LocalService`, never to SYSTEM.

### The authd path

For any other identity, peinit asks [authd](/peios/security-fundamentals/boot-and-trust-establishment/authd-handoff.md) 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](/peios/security-fundamentals/identity/overview.md), 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.

> [!IMPORTANT]
> Every non-SYSTEM service start depends on authd. peinit interacts with it over a non-blocking, timed channel — if authd is unreachable or unresponsive, the service start *fails* rather than hanging PID 1. This is why a broken authd takes down all non-platform service starts but leaves the SYSTEM-minted platform services unaffected. The authd wire interface is owned by authd, not by peinit, and is still being specified.

## 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](/peios/security-fundamentals/access-decisions/overview.md). See [Well-known principals](/peios/security-fundamentals/identity/well-known-principals.md) 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](/peios/security-fundamentals/privileges/overview.md). `RequiredPrivileges` lets a definition trim that set down to only what the service needs:

- peinit reads the `RequiredPrivileges` allow-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 `RequiredPrivileges` is 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](/peios/security-fundamentals/privileges/overview.md) 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](/peios/using-peios/services-and-jobs/jobs-and-operations.md)** | 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.

> [!WARNING]
> peinit does **not** validate filesystem permissions on hook binaries. If `HookIdentity` grants elevated privileges, it is the administrator's responsibility to ensure the hook binary and every parent directory are not writable by a lower-privileged identity — otherwise a less-trusted principal could replace the hook and run code as the elevated identity. ([FACS](/peios/security-fundamentals/file-access/overview.md) enforces KACS descriptors on managed filesystems, but peinit itself performs no such check — protecting hook binaries still depends on correct SDs from packaging and controlled paths.)

## Security invariants

The identity model rests on a few rules peinit never breaks:

1. **peinit never shares its SYSTEM token.** Even `Identity=SYSTEM` services get a separately minted token.
2. **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.
3. **Privileges are subtractive only.** `RequiredPrivileges` removes; it can never add.
4. **Identity is deterministic.** Every service runs as a known principal. `SYSTEM` must be declared explicitly; an empty `Identity` is the minimal `LocalService`, never SYSTEM.

## Where to start

`Identity` decides what a service *can do*; the [ServiceSecurity descriptor](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md) decides *who can manage it* — two independent concerns. Read [Who can manage a service](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md) for the other half.

To see how the token is installed alongside the rest of the process setup, read [The execution environment](/peios/using-peios/services-and-jobs/execution-environment.md).

For the identity primitives themselves — tokens, SIDs, privileges — start at [Tokens](/peios/security-fundamentals/tokens/overview.md).

---

# The execution environment

_Peios / Using Peios / Services & jobs_

> What a service process starts with — its cgroup tree, stdio, layered environment, limits, hooks, conditions and asserts, and the fd store.

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](/peios/using-peios/services-and-jobs/identity-and-privileges.md) (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](#the-fd-store). 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](/peios/using-peios/services-and-jobs/shutdown.md); 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](/peios/using-peios/threads-and-processes/process-lifecycle.md) 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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) 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](/peios/using-peios/services-and-jobs/supervision.md).

## 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](/peios/using-peios/services-and-jobs/output-and-logging.md).

### 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.

> [!WARNING]
> Attaching a terminal **suppresses log capture** — the pipes eventd would read are closed, so the service's output goes to the terminal and nowhere else. Don't set `TTYPath` on a service whose output you expect to find in the logs.
>
> Two services pointed at the same terminal will fight over it: both read the same input, and neither is told about the other. peinit does not currently detect this. Until it does, treat "one service per terminal" as a rule you enforce yourself, or declare a `Conflicts` between them.

## The environment

peinit constructs each service's environment in layers, lowest precedence first — it does **not** pass through its own near-empty startup environment.

1. **Compiled-in base.** A fixed floor peinit always provides — currently just `PATH`:
   `/bin`.
2. **Global `EnvVars`.** Each value under `Machine\System\Init\EnvVars\` becomes a variable (value name → variable name). `EnvVars\PATH` overrides the base `PATH`; other names add. Every service gets this layer except **registryd**, which serves the key itself — see the warning below for why that exemption exists.
3. **Per-service `Environment`.** The definition's own `KEY=VALUE` entries, which override layers 1–2.
4. **Protocol variables.** `NOTIFY_SOCKET` (always) and `LISTEN_FDS` / `LISTEN_FDNAMES` (only when [stored fds](#the-fd-store) are injected). These have the **highest** precedence and cannot be overridden — a service that clobbered `NOTIFY_SOCKET` would break [sd_notify](/peios/using-peios/services-and-jobs/service-types.md).

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](/peios/using-peios/services-and-jobs/identity-and-privileges.md) (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`.

> [!WARNING]
> Write access to `Machine\System\Init\EnvVars\` is equivalent to compromising **every service peinit starts** — a principal who can set it can inject `LD_PRELOAD`, `LD_LIBRARY_PATH`, and the like into all of them. peinit deliberately does not filter variable names; the key's [security descriptor](/peios/using-peios/registry-security/access-control.md) is the control boundary. Keep it tight — the recommended default is SYSTEM full, Administrators read-only.
>
> This is also why **registryd** is the one service that never receives this layer: it is the daemon that *serves* the key. Letting the key inject into the process that enforces who may write it would make the control boundary self-referential. Every other service, platform daemons included, gets the ordinary layering.

## 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](/peios/using-peios/registry-security/access-control.md) granting full access to SYSTEM, Administrators, and the service's own [SID](/peios/using-peios/services-and-jobs/identity-and-privileges.md), 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](/peios/using-peios/services-and-jobs/registry-key-reference.md).

## Pre-exec and post-exec hooks

Hooks let a service do work *around* its main process without baking it into the binary.

- **`ExecStartPre`** runs *before* the main binary. Each command runs in sequence, in the `hooks/` sub-cgroup; **any** hook exiting non-zero aborts the start (the whole cgroup is killed and the service Fails with `PreHookFailure`). Use it for prerequisites that must hold before the service starts — creating a runtime directory, waiting on a precondition.
- **`ExecStartPost`** runs *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`](/peios/using-peios/services-and-jobs/identity-and-privileges.md) 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](/peios/using-peios/services-and-jobs/controlling-services.md).

## 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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md), 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](/peios/using-peios/services-and-jobs/overview.md):

- **`registry:` checks are cache-only.** A `registry:` check may only name a key peinit already caches (under `Machine\System\Services\` or `Machine\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 a `stat()` on the event loop, because `stat()` 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](/peios/using-peios/services-and-jobs/service-types.md):

- A service stores an fd by sending `FDSTORE=1` with the fd attached (and optionally `FDNAME=<name>`; the default name is `stored`). peinit holds it.
- It removes one with `FDSTOREREMOVE=1` and `FDNAME=<name>`.
- On the **next start**, peinit injects the stored fds starting at fd 3, sets `LISTEN_FDS` to the count and `LISTEN_FDNAMES` to the colon-separated names, then clears the store. (This is the same `LISTEN_FDS` convention 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](/peios/using-peios/services-and-jobs/defining-a-service.md) 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](/peios/using-peios/services-and-jobs/identity-and-privileges.md).

For what happens to the stdout/stderr pipes peinit holds, read [Service output and logging](/peios/using-peios/services-and-jobs/output-and-logging.md).

For how conditions, asserts, and hooks fit into the start sequence and its states, read [The service lifecycle](/peios/using-peios/services-and-jobs/the-service-lifecycle.md).

---

# Controlling services

_Peios / Using Peios / Services & jobs_

> peiosctl and the control socket — the verbs and their rights, wait semantics, the command-by-state matrix, status output, and error codes.

`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:

```json
{"command": "start", "service": "jellyfin", "wait": true}
{"status": "ok", "operation_id": "a1b2c3d4-...", "service": "jellyfin", "state": "active", "cause": "explicit_start", "warnings": []}
```

Two properties are worth knowing even if you only ever use `peiosctl`:

- **Every command is access-controlled.** When you connect, peinit captures your [token](/peios/using-peios/services-and-jobs/identity-and-privileges.md) from the kernel and runs [AccessCheck](/peios/security-fundamentals/access-decisions/overview.md) 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](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md).
- **Lifecycle commands create [operations](/peios/using-peios/services-and-jobs/jobs-and-operations.md).** A `start`/`stop`/`restart`/`reload`/`reset` returns an `operation_id` — a GUID you can poll. Conflict resolution between concurrent commands happens at the operation layer, which is why two simultaneous `start`s 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](/peios/using-peios/services-and-jobs/shutdown.md). `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.

> [!NOTE]
> A connection blocked on a `--wait` operation is *not* counted as idle, so it is not closed by `ConnectionTimeout`. It stays open until the operation resolves, bounded by the operation's own timeout (e.g. `StartTimeout`).

## 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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md):

| 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 `start` is 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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) 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:

```json
{
    "status": "ok",
    "service": "jellyfin",
    "state": "active",
    "cause": "explicit_start",
    "status_text": "Listening on port 8096",
    "current_job": {"id": "a1b2...", "type": "service_main", "pid": 1234, "started_at": "...", "identity": "jellyfin-svc"},
    "current_operation": {"id": "e5f6...", "type": "start", "source": "admin"},
    "health": "healthy",
    "uptime_seconds": 86400,
    "definition_removed": false,
    "warnings": []
}
```

- `state` and `cause` are the [lifecycle](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) pair — read them together.
- `status_text` is the latest `STATUS=` string the service sent via sd_notify (`null` if never sent; cleared on each restart).
- `current_job` and `current_operation` are the [job and operation](/peios/using-peios/services-and-jobs/jobs-and-operations.md) GUIDs, or `null`.
- `health` is `healthy`, `unhealthy`, `unknown`, or `null` (no health check).
- `definition_removed` is `true` when the definition was deleted but an instance is still [draining](/peios/using-peios/services-and-jobs/defining-a-service.md).
- `warnings` lists 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:

```json
{"status": "ok", "services": [
    {"service": "jellyfin", "state": "active", "cause": "explicit_start", "health": "healthy"},
    {"service": "registryd", "state": "active", "cause": "dependency_start", "health": null}
]}
```

`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](/peios/using-peios/services-and-jobs/defining-a-service.md) 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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md).

To understand the operations every lifecycle command creates and how to poll them, read [Jobs and operations](/peios/using-peios/services-and-jobs/jobs-and-operations.md).

To configure who may run each command, read [Who can manage a service](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md).

---

# Who can manage a service

_Peios / Using Peios / Services & jobs_

> Every control command runs AccessCheck against the service's ServiceSecurity descriptor. The rights, the defaults, and the system control descriptor.

A service is a *securable object*. Just as a [file](/peios/security-fundamentals/file-access/overview.md) or a [registry key](/peios/using-peios/registry-security/access-control.md) carries a [security descriptor](/peios/security-fundamentals/security-descriptors/overview.md) 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](/peios/using-peios/services-and-jobs/controlling-services.md) — 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](/peios/using-peios/services-and-jobs/identity-and-privileges.md), 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](/peios/using-peios/registry-security/access-control.md), 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](/peios/using-peios/registry-security/access-control.md) 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:

1. peinit captures the caller's [token](/peios/using-peios/services-and-jobs/identity-and-privileges.md) from the kernel (`kacs_open_peer_token`) — the caller's *effective* identity at connection time, so if the caller is [impersonating](/peios/security-fundamentals/impersonation/overview.md), the impersonated identity is what is checked.
2. peinit resolves the target service and its ServiceSecurity descriptor.
3. peinit runs [AccessCheck](/peios/security-fundamentals/access-decisions/overview.md): the caller's token against the descriptor, for the right the command needs.
4. **Denied** → return `ACCESS_DENIED` and **log the attempt** (caller SID, target service, requested right).
5. **Granted** → execute the command.

> [!NOTE]
> Access denials are always logged — caller, target, and the right requested. Silent denial is a specification violation. If you are debugging a denial, the audit record has everything you need; see [Debugging a denial](/peios/security-fundamentals/access-decisions/debugging-a-denial.md).

## 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](/peios/using-peios/registry-concepts/watches.md). This is why ServiceSecurity is in its own [mutability class](/peios/using-peios/services-and-jobs/defining-a-service.md) — 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.** `status` is per-service access-controlled; `list` filters.

## 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](/peios/using-peios/services-and-jobs/identity-and-privileges.md).

For the commands these rights gate, read [Controlling services](/peios/using-peios/services-and-jobs/controlling-services.md).

For the descriptor and AccessCheck machinery itself, read [Security descriptors](/peios/security-fundamentals/security-descriptors/overview.md) and [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

---

# Jobs and operations

_Peios / Using Peios / Services & jobs_

> A job is one process execution; an operation is one requested action. Their lifecycles, types, and GUIDs — plus ad-hoc jobs submitted through JFS.

A [service](/peios/using-peios/services-and-jobs/defining-a-service.md) is a long-lived *definition*. But when you read a status output or query the [event log](/peios/security-fundamentals/auditing/overview.md), 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](/peios/using-peios/services-and-jobs/execution-environment.md), a [health check](/peios/using-peios/services-and-jobs/supervision.md), or an [ad-hoc run](#ad-hoc-jobs) — 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](#ad-hoc-jobs). |

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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md), 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](/peios/security-fundamentals/auditing/overview.md) 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](/peios/using-peios/services-and-jobs/output-and-logging.md) 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](/peios/security-fundamentals/impersonation/overview.md) 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.

> [!IMPORTANT]
> An ad-hoc job runs with **the submitter's own (possibly impersonated) token** — there is no identity field in an ad-hoc request. A caller cannot name an arbitrary identity; it can only pass through a token it already holds. This is what prevents escalation: a service cannot manufacture a job running as an identity it does not already possess.

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.

> [!NOTE]
> JFS is a kernel subsystem with its own byte-level interface, still being specified. peinit is its consumer; the *shape* of the protocol is settled, but the wire details are owned by JFS, so this path is not fully implementable from the peinit specification alone.

## 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](/peios/using-peios/services-and-jobs/controlling-services.md) 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](/peios/using-peios/services-and-jobs/dependencies.md). |
| `RestartPolicy` | An automatic [restart](/peios/using-peios/services-and-jobs/supervision.md). |
| `Timer` | A [timer](/peios/using-peios/services-and-jobs/triggers-and-timers.md) 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

```mermaid
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](/peios/using-peios/services-and-jobs/controlling-services.md) 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_job` and `current_operation` give the GUIDs of the service's active execution and active action (or `null`).
- **`operation-status <id>`** — polls one operation to a terminal state; a `--wait` lifecycle command does this for you under the hood.
- **[eventd](/peios/security-fundamentals/auditing/overview.md)** — 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](/peios/using-peios/services-and-jobs/controlling-services.md).

To understand the service states an operation drives a service through, read [The service lifecycle](/peios/using-peios/services-and-jobs/the-service-lifecycle.md).

To query the durable job and operation history, read [Auditing](/peios/security-fundamentals/auditing/overview.md).

---

# Service output and logging

_Peios / Using Peios / Services & jobs_

> peinit captures every service's stdout and stderr, buffers before eventd exists, and forwards after — best-effort logs, guaranteed audit events.

peinit is **not** a logging system — storage, indexing, and queries belong to [eventd](/peios/security-fundamentals/auditing/overview.md). 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](/peios/using-peios/services-and-jobs/execution-environment.md).) It reads output **line by line** and tags each line with:

- the **service name**,
- the **stream** (stdout or stderr),
- a **timestamp**,
- the **[job](/peios/using-peios/services-and-jobs/jobs-and-operations.md) 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](/peios/security-fundamentals/auditing/events-and-transport.md), 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](/peios/using-peios/services-and-jobs/supervision.md) 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.

> [!NOTE]
> Signals are never starved. In every loop iteration, child reaping (SIGCHLD) and shutdown handling take priority over all other event sources, including log reading. A flood of service output can never delay peinit noticing that a service died or that the system is shutting down.

## The eventd handoff

When eventd reaches Active, peinit switches from buffering to forwarding:

1. It begins sending to eventd's log datagram socket (path from `Machine\System\eventd\LogSocketPath`).
2. It **replays the pre-eventd buffer**, oldest first, preserving each line's timestamp and metadata. This replay is best-effort — the records are datagrams on a loss-tolerant socket and some may be dropped under load; peinit never blocks waiting to deliver them.
3. It switches to real-time forwarding — new output goes out as it arrives.
4. It clears the buffer.

From then on peinit is a pipe relay: read 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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md), 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](/peios/using-peios/services-and-jobs/execution-environment.md).

To follow the job GUID that tags every log line, read [Jobs and operations](/peios/using-peios/services-and-jobs/jobs-and-operations.md).

For where the logs and events actually go — storage, indexing, and queries — read [Auditing](/peios/security-fundamentals/auditing/overview.md).

---

# Boot and boot modes

_Peios / Using Peios / Services & jobs_

> The two-phase boot — hardcoded bootstrap, then registry-driven — and the Full, Safe, and Recovery modes with the boot-attempt counter between them.

peinit's boot has two phases and a chicken-and-egg problem to solve. The problem: peinit reads everything it does from the [registry](/peios/using-peios/registry-concepts/overview.md), 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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md) 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`, `/dev` are 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](/peios/security-fundamentals/boot-and-trust-establishment/peinit-pid-1.md).

## 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:

1. **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.
2. **Mount the remaining virtual filesystems** — `/dev/pts`, `/dev/shm`, `/run`, `/sys/fs/cgroup` — mounting each only if absent. A failure here → Recovery.
3. **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.
4. **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.
5. **Set the clock from the hardware RTC**, so early timestamps and the boot counter are meaningful. A failure here → Recovery.
6. **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.
7. **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 marked `Required=1` that cannot be provisioned sends boot → Recovery. The individual keys are cataloged in the [registry key reference](/peios/using-peios/services-and-jobs/registry-key-reference.md).
8. **Infrastructure setup** — create the [control socket](/peios/using-peios/services-and-jobs/controlling-services.md), open the JFS device for [ad-hoc jobs](/peios/using-peios/services-and-jobs/jobs-and-operations.md), 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](#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.

> [!CAUTION]
> Packaged images, VM templates, and live ISOs must not ship a populated `/lcl/etc/machine-id` or a `/var/state/peinit/random-seed` file. A shipped machine-id gives every clone the same identity, and a shipped seed is a public value that is not acceptable entropy. Clone and reset tooling should remove or truncate `/lcl/etc/machine-id` so peinit generates a fresh ID on the next boot, and should never bake a seed into the image — if you need strong first-boot randomness for a stateless image, provide a real kernel entropy source (hardware RNG or virtio-rng) instead.

### 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:

1. **Read all definitions** under `Machine\System\Services\`. (A registry read timing out here → Recovery.)
2. **Build and validate the dependency graph** from the boot-triggered services and their transitive [dependency closure](/peios/using-peios/services-and-jobs/dependencies.md). Validation runs *before* anything starts.
3. **Start services in dependency order**, in [parallel](/peios/using-peios/services-and-jobs/dependencies.md) up to `MaxParallelStarts`, with [readiness gating](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) 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](/peios/using-peios/services-and-jobs/triggers-and-timers.md) services are excluded from the graph (but kept in the model for on-demand start). The whole boot runs against one [snapshot](/peios/using-peios/services-and-jobs/defining-a-service.md) — 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](/peios/using-peios/services-and-jobs/identity-and-privileges.md). 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.

```mermaid
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](#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."

> [!NOTE]
> `SafeMode=1` and `ErrorControl=Critical` are *filters within the boot-triggered set*. They do not make a demand-only service auto-start in Safe mode — a service with no `boot` trigger stays demand-only regardless.

### 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](#registryd-and-loregd) 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. |

> [!IMPORTANT]
> Recovery mode requires console access — physical, IPMI, or serial. There is no remote recovery in the current design (emergency SSH and registry historical reversion are noted as post-v1 work). Plan console access for any machine you need to be able to recover.

## 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 with `peios.bootattempts=N` on the kernel command line, or set it to `0` to 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](/peios/using-peios/services-and-jobs/supervision.md) 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](/peios/using-peios/services-and-jobs/shutdown.md) 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](#safe-mode). |
| `peios.recovery=1` | Force [Recovery mode](#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`](/peios/using-peios/services-and-jobs/execution-environment.md). 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](#recovery-mode), halting with no shell, a Critical service about to force a reboot) is worth one corrupted line of somebody else's session.

[prelude](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md) 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.

> [!WARNING]
> Suppressed messages are **dropped, not stored**. Until peinit can forward them to [eventd](/peios/security-fundamentals/auditing/overview.md), `peios.quiet=1` means you lose peinit's narrative from the moment a login prompt appears. On a machine you are debugging, boot with `peios.quiet=0`.

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.

> [!NOTE]
> There is no token that selects services. Which services start is decided entirely by what is defined under `Machine\System\Services` — see [Defining a service](/peios/using-peios/services-and-jobs/defining-a-service.md). A console shell or a login prompt is an ordinary service definition with a [`TTYPath`](/peios/using-peios/services-and-jobs/execution-environment.md), not a boot flag.

## Where to start

To understand the graph validation and parallel start that drive Phase 2, read [Dependencies and ordering](/peios/using-peios/services-and-jobs/dependencies.md).

To understand the Critical-failure reboot path that feeds the boot counter, read [Keeping services running](/peios/using-peios/services-and-jobs/supervision.md).

For the trust and token side of early boot, read [peinit at PID 1](/peios/security-fundamentals/boot-and-trust-establishment/peinit-pid-1.md).

---

# Shutdown

_Peios / Using Peios / Services & jobs_

> Shutdown is boot in reverse — SIGTERM to SIGKILL in reverse dependency order, bounded by a global timeout, then seed, unmount, sync, and power off.

Shutdown is [boot](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) run in reverse. peinit stops services in **reverse [dependency](/peios/using-peios/services-and-jobs/dependencies.md) 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](/peios/using-peios/services-and-jobs/supervision.md), 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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md), 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:

1. **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` — *except* `status` queries, which keep working so you can watch progress.
2. **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.
3. **Clear Completed services.** Oneshot services sitting in [Completed](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) (with `RemainAfterExit`) have no process; peinit moves them to Inactive so their dependents can be stopped cleanly.
4. **Stop services in reverse dependency order**, in waves. Each graceful-stop-eligible service (those in Active or Reloading) gets SIGTERM and `StopTimeout` seconds to exit; if it does not, peinit SIGKILLs its whole cgroup. A service is never stopped until everything that `Requires` or `BindsTo` it has already stopped. Services that are Starting are not stopped gracefully — their startup is cancelled and the cgroup SIGKILLed.
5. **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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) (leaked), and shutdown continues regardless.
6. **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.
7. **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 final `sync()` and the kernel, never blocking shutdown. The root filesystem (`/`) is never unmounted — once the rest are handled, peinit remounts root read-only.
8. **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](/peios/using-peios/services-and-jobs/dependencies.md); 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](/peios/using-peios/services-and-jobs/service-types.md). 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](/peios/using-peios/services-and-jobs/supervision.md)) — 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.

> [!NOTE]
> Reaping orphans matters: when any process anywhere on the system dies leaving children, those children are reparented to PID 1. peinit reaps them so they do not accumulate as zombies — even though they were never its services. This is part of the job of being PID 1, distinct from supervising services.

## Where to start

To understand the reverse ordering and which relationships gate it, read [Dependencies and ordering](/peios/using-peios/services-and-jobs/dependencies.md).

To understand the Critical-failure path that bypasses graceful shutdown, read [Keeping services running](/peios/using-peios/services-and-jobs/supervision.md).

For the boot side of the lifecycle and the reboot/recovery escalation, read [Boot and boot modes](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md).

---

# Troubleshooting peinit

_Peios / Using Peios / Services & jobs_

> A symptom-first guide — a service that won't start or keeps restarting, a change that didn't land, an access denial, and a machine stuck rebooting.

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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) 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](/peios/security-fundamentals/auditing/overview.md); peinit emits every [job and operation](/peios/using-peios/services-and-jobs/jobs-and-operations.md) 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](/peios/using-peios/services-and-jobs/execution-environment.md) 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](/peios/using-peios/services-and-jobs/execution-environment.md) 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](/peios/using-peios/services-and-jobs/identity-and-privileges.md) 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](/peios/using-peios/services-and-jobs/service-types.md) 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). |

> [!TIP]
> A startup failure (`ReadinessTimeout`, `PreHookFailure`, `PreExecFailure`, `ParentSetupFailure`) is **restart-eligible** — peinit will have retried it through [Backoff](/peios/using-peios/services-and-jobs/supervision.md) before landing in Failed. So by the time you see `Failed`, it has already failed repeatedly. The logs show each attempt.

## A service keeps restarting

The service flaps — up, down, up, down. This is [restart policy](/peios/using-peios/services-and-jobs/supervision.md) doing its job, but it points at an unstable service.

- **It eventually settles in `Failed` with `RestartBudgetExhausted`.** It crashed `RestartMaxRetries` times faster than it could stay healthy for `RestartWindow`. 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](/peios/using-peios/services-and-jobs/supervision.md) 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=Always` restarts even successful exits (cause `CleanExitRestart`). If the service is *meant* to exit, use `OnFailure` instead.

## The machine keeps rebooting

A reboot loop almost always means a **Critical service is failing**. A [`ErrorControl=Critical`](/peios/using-peios/services-and-jobs/supervision.md) service that exhausts its budget makes peinit sync and reboot; if it fails the same way every boot, you loop.

The [boot-attempt counter](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) is designed to break this: after N attempts (default 3) peinit stops and drops you into [Recovery mode](#booted-into-recovery-mode). From the Recovery shell, find the failing Critical service (its logs are in [eventd](/peios/security-fundamentals/auditing/overview.md), 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](/peios/using-peios/services-and-jobs/execution-environment.md), 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](/peios/using-peios/services-and-jobs/defining-a-service.md), and each field has a **mutability class** that decides when a change applies:

- **Immutable at runtime** (`ImagePath`, `Type`, `Identity`, `RequiredPrivileges`, `ErrorControl`) → takes effect only on `restart`.
- **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](/peios/using-peios/registry-concepts/watches.md) rather than having them pushed, so there can be a brief lag.

## ACCESS_DENIED

A control command returns `ACCESS_DENIED`. peinit ran [AccessCheck](/peios/security-fundamentals/access-decisions/overview.md) 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](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md) (`start` needs `SERVICE_START`, `restart` needs both stop and start, …).
- `shutdown` and `reload-config` need `SYSTEM_SHUTDOWN` / `SYSTEM_RELOAD_CONFIG` in peinit's control descriptor.
- The check uses your **effective** identity at connect time — if you are [impersonating](/peios/security-fundamentals/impersonation/overview.md), that is what is checked.

Every denial is logged with the caller SID, target, and requested right. Walk it through [Debugging a denial](/peios/security-fundamentals/access-decisions/debugging-a-denial.md).

> [!NOTE]
> If `list` shows fewer services than you expect, that is not a bug — `list` **omits** services you lack `SERVICE_QUERY_STATUS` on rather than denying them. You are seeing exactly what your token can see.

## A dependent never started

You started (or booted) a service, but something that depends on it never came up:

- A **`Requires`/`BindsTo` dependent stays blocked** until its target reaches a [satisfying state](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) (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 to `Readiness=Notify` so it signals `READY=1` when actually ready. (peinit logs this as a validation warning at boot.)
- A **`Wants` dependent starts regardless** of its target — if you needed it to wait, `Wants` was the wrong relationship; use `Requires`.

## Booted into Safe mode

[Safe mode](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) 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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) 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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) 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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) handy.

For restart, backoff, and Critical-reboot behaviour, see [Keeping services running](/peios/using-peios/services-and-jobs/supervision.md).

For the commands and their outputs, see [Controlling services](/peios/using-peios/services-and-jobs/controlling-services.md).

---

# Registry key reference

_Peios / Using Peios / Services & jobs_

> The complete catalog — every service-definition field with its registry type and default, and every key peinit reads or writes.

This page is the reference catalog for everything peinit reads from or writes to the [registry](/peios/using-peios/registry-concepts/overview.md): 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.

> [!NOTE]
> The registry stores a value's type but not its meaning — that is what [`regman`](/peios/using-peios/registry-administration/regman.md) and these docs are for. A value peinit accepts at write time can still be rejected when peinit reads and validates it; this page lists the schema, not the validation rules (those live on each field's page).

## Registry value types

Service definition fields map onto registry [value types](/peios/using-peios/registry-concepts/keys-values-and-types.md) 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](/peios/using-peios/services-and-jobs/defining-a-service.md) and the linked pages.

**Execution** — see [The execution environment](/peios/using-peios/services-and-jobs/execution-environment.md)

| 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. |

> [!NOTE]
> Each `RuntimeDirectories` entry is a plain relative name — just `foo`, never a path — and must not contain `/`, `\`, `.`, `..`, NUL, or control characters. For an entry `foo`, peinit creates `/run/foo` immediately before launching the main process and applies a Peios file security descriptor granting full access to SYSTEM, Administrators, and this service's own SID. If the directory can't be created or the 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 that the next boot clears for you.

**Type and readiness** — see [Simple and Oneshot services](/peios/using-peios/services-and-jobs/service-types.md)

| 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](/peios/using-peios/services-and-jobs/triggers-and-timers.md)

| 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](/peios/using-peios/services-and-jobs/identity-and-privileges.md)

| 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](/peios/using-peios/services-and-jobs/dependencies.md)

| 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](/peios/using-peios/services-and-jobs/supervision.md)

| 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](/peios/using-peios/services-and-jobs/the-service-lifecycle.md) and [Controlling services](/peios/using-peios/services-and-jobs/controlling-services.md)

| 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](/peios/using-peios/services-and-jobs/execution-environment.md); 0 disables it. |
| `ServiceSecurity` | binary | inherit parent | [Descriptor](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md) 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](/peios/using-peios/services-and-jobs/defining-a-service.md) |
| `Machine\System\Services\SchemaVersion` | dword | Schema version guard (currently 1). | [Defining a service](/peios/using-peios/services-and-jobs/defining-a-service.md) |
| `Machine\System\Services\<name>\LastTimerRun` | `REG_QWORD` | Last-run timestamp for a single-trigger persistent timer. Written by peinit. | [Triggers and timers](/peios/using-peios/services-and-jobs/triggers-and-timers.md) |
| `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](/peios/using-peios/services-and-jobs/triggers-and-timers.md) |

## 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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) |
| `BootSuccessGrace` | dword | 30 | Seconds a Critical service must hold a satisfying state before boot is successful. | [Boot and boot modes](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) |
| `ShutdownTimeout` | dword | 90 | Maximum seconds for the whole graceful shutdown. | [Shutdown](/peios/using-peios/services-and-jobs/shutdown.md) |
| `SettleTimeout` | dword | 5 | Seconds to wait for the boot set to settle before starting [`boot:settled`](/peios/using-peios/services-and-jobs/triggers-and-timers.md) services anyway. | [Triggers and timers](/peios/using-peios/services-and-jobs/triggers-and-timers.md) |
| `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](/peios/using-peios/services-and-jobs/shutdown.md) |

## 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](/peios/using-peios/services-and-jobs/who-can-manage-a-service.md) |
| `MaxControlConnections` | dword | 32 | Maximum concurrent control-socket connections. | [Controlling services](/peios/using-peios/services-and-jobs/controlling-services.md) |
| `MaxRequestSize` | dword | 65536 | Maximum control-socket request size (bytes). | [Controlling services](/peios/using-peios/services-and-jobs/controlling-services.md) |
| `ConnectionTimeout` | dword | 30 | Seconds before an idle control connection is closed. | [Controlling services](/peios/using-peios/services-and-jobs/controlling-services.md) |
| `MaxLogLineLength` | dword | 8192 | Maximum bytes per service output line before truncation. | [Service output and logging](/peios/using-peios/services-and-jobs/output-and-logging.md) |
| `MaxLogBufferPerService` | dword | 65536 | Maximum bytes buffered per service pipe before back-pressure. | [Service output and logging](/peios/using-peios/services-and-jobs/output-and-logging.md) |
| `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](/peios/using-peios/services-and-jobs/output-and-logging.md) |
| `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](/peios/using-peios/services-and-jobs/output-and-logging.md) |
| `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](/peios/using-peios/services-and-jobs/execution-environment.md) |
| `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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) |
| `ProvisionedPaths\<name>\Kind` | string | *(required)* | `directory` or `file` — what to create at `Path`. | [Boot and boot modes](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) |
| `ProvisionedPaths\<name>\Path` | string | *(required)* | Absolute path to create or verify. | [Boot and boot modes](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) |
| `ProvisionedPaths\<name>\Security` | binary | SYSTEM + Administrators full, users read/traverse | Peios file security descriptor to apply to the object. | [Boot and boot modes](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) |
| `ProvisionedPaths\<name>\Required` | dword | 0 | If 1, a failure to provision this entry sends boot to [Recovery mode](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) before Phase 2 starts. | [Boot and boot modes](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) |

> [!NOTE]
> A few `ProvisionedPaths` rules worth knowing before you rely on them: `Kind=file` never truncates an existing file (it only makes sure the file is present), and peinit does *not* create missing parent directories for you — define each directory you need as its own entry, or depend on the package that owns the parent. A malformed entry is logged and skipped rather than blocking boot; only an entry you mark `Required=1` can send boot to [Recovery mode](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md), and only when it genuinely can't be created or secured.

## 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](/peios/using-peios/services-and-jobs/output-and-logging.md) |

## Where to start

For how these fields combine into a working definition and when changes take effect, read [Defining a service](/peios/using-peios/services-and-jobs/defining-a-service.md).

To look up the meaning, valid range, and effect-timing of any key on a live system, use [`regman`](/peios/using-peios/registry-administration/regman.md).

---

# Mount policies

_Peios / Using Peios / Mount policies_

> Every mounted filesystem carries a mount policy — one per-superblock setting deciding whether FACS applies and how missing SDs are handled.

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](/peios/using-peios/mount-policies/policy-classes.md) covers each in detail with the missing-SD and corrupt-SD rules; [SD storage by filesystem](/peios/using-peios/mount-policies/sd-storage-by-filesystem.md) covers how each filesystem type physically stores the SD; [Managing mounts](/peios/using-peios/mount-policies/managing-mounts.md) 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/peios` to `/old/etc/peios` does 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:

1. **What happens when an inode has no SD attached.** Different classes handle this differently — deny, synthesise-in-memory, or synthesise-and-persist.
2. **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).
3. **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](/peios/using-peios/mount-policies/policy-classes.md).

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](/peios/using-peios/mount-policies/sd-storage-by-filesystem.md).

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](/peios/using-peios/mount-policies/managing-mounts.md).

## The commands

The concept pages above describe the model; three command-line tools put it to work. [`mount`](/peios/using-peios/mount-policies/mount.md) 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`](/peios/using-peios/mount-policies/umount.md) detaches a filesystem again, by mount point or by source, with lazy, forced, recursive and all-targets variants. [`lsblk`](/peios/using-peios/mount-policies/lsblk.md) 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, the synthesis chain that produces an SD when one is missing (parent → mount template → fallback), and the universal corrupt-SD rule.

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:

1. The kernel reads the file's SD from the filesystem (typically an xattr).
2. If no SD is present, the access fails. The kernel returns `-EACCES` for any operation that would require an SD-based access check.
3. 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:

1. The kernel reads the file's SD from the filesystem.
2. If no SD is present, the kernel **synthesises** one using the synthesis chain (covered below).
3. The synthesised SD is used for the access check.
4. 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.
5. 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:

1. The kernel reads the file's SD from the filesystem.
2. If no SD is present, the kernel synthesises one using the synthesis chain.
3. The synthesised SD is used for the access check — immediately, from the kernel's cache.
4. The synthesised SD is **also** written back to the filesystem (typically as the SD xattr). The file now has a persistent SD.
5. 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:

- `/proc` is `unmanaged`. Access to `/proc/<pid>/*` files is governed by the process-level checks (process SD + PIP, per the two-check rule), not by FACS.
- `/sys` is `unmanaged`. Writes are restricted to `BUILTIN\Administrators` and `SYSTEM` by 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.

```mermaid
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:

1. **Parent directory inheritance.** The kernel reads the parent directory's SD and computes what a newly-created child would inherit (per the [Inheritance](/peios/security-fundamentals/security-descriptors/inheritance.md) rules). If this produces a usable SD (with inheritable ACEs from the parent), that SD is used.
2. **Mount-level SD template.** Each FACS-managed mount can carry a default SD template — set via `kacs_set_mount_policy` along 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.
3. **Hardcoded fallback.** If neither the parent nor the template produces an SD, the kernel uses a hardcoded fallback: `GENERIC_ALL` to SYSTEM and `BUILTIN\Administrators`; `GENERIC_READ | GENERIC_EXECUTE` to 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:

1. Detects the corruption when reading the SD.
2. Returns `-EACCES` for the access.
3. 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](/peios/using-peios/mount-policies/managing-mounts.md)). The interesting transitions:

- **`facs_synthesize_persistent` → `facs_deny_missing`.** Common during migration. As files are adopted by `synthesize_persistent`, they accumulate SDs. Once enough have been adopted, switch to `deny_missing` to 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](/peios/using-peios/mount-policies/managing-mounts.md).

## Where to go next

For how each filesystem physically stores the SD, read [SD storage by filesystem](/peios/using-peios/mount-policies/sd-storage-by-filesystem.md).

For the syscalls that set and read a mount's policy, read [Managing mounts](/peios/using-peios/mount-policies/managing-mounts.md).

For choosing a policy from the command line at attach time, read [mount](/peios/using-peios/mount-policies/mount.md).

---

# SD storage by filesystem

_Peios / Using Peios / Mount policies_

> How each filesystem stores SDs — ext4 xattrs and ea_inode, NTFS round-tripping via system.ntfs_security, FAT's in-memory synthesis — and the implications.

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`](/peios/using-peios/disks-and-filesystems/mke2fs.md). 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_sd` call with an error indicating the filesystem cannot hold the SD.
- Or, in the `facs_synthesize_persistent` case, 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](/peios/security-fundamentals/file-access/special-cases.md) 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](/peios/using-peios/disks-and-filesystems/stratafs/overview.md) 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](/peios/security-fundamentals/security-descriptors/inheritance.md) 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](/peios/using-peios/mount-policies/policy-classes.md).

For setting and reading a mount's policy at runtime, read [Managing mounts](/peios/using-peios/mount-policies/managing-mounts.md).

For the structure of the SD being stored, read [Security descriptors](/peios/security-fundamentals/security-descriptors/overview.md).

---

# Managing mounts

_Peios / Using Peios / Mount policies_

> kacs_set_mount_policy and kacs_get_mount_policy, the generation counter behind lazy invalidation, and the SeTcbPrivilege requirement.

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:

1. Validates the fd and identifies the superblock.
2. Validates the policy value. Attempts to set `KACS_MOUNT_POLICY_UNMANAGED` (which is not settable via this ABI) are rejected with `-EINVAL`.
3. Validates the template SD if provided — must parse, must be within size limits.
4. Checks the caller's privileges. `SeTcbPrivilege` is required.
5. Atomically updates the superblock's policy class, the template SD, and increments the generation counter.
6. Writes the new generation to `args.generation`.
7. Returns 0.

The change is **atomic at the superblock level** — every future access against any inode on this superblock sees the new policy. There is no transitional state where some inodes are under the old policy and others under the new.

### Privilege requirement

> [!IMPORTANT]
> `SeTcbPrivilege` is the privilege gating this call. The privilege is held by peinit and a handful of other TCB components; not by ordinary services or administrators. In practice this means mount policy is configured at boot (by peinit, applying mount-time configuration) or by a privileged management daemon. Ad-hoc policy changes are rare.

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:

1. Validates the fd, identifies the superblock.
2. Checks the caller's privileges. `SeTcbPrivilege` is required to read.
3. Writes the policy class, generation, and template to `args` (template only if a buffer was provided).
4. Returns 0.

Like the write syscall, this 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:

1. Each in-kernel cached SD or synthesised entry records the generation it was derived from.
2. At access time, the kernel compares the cached entry's generation against the superblock's current generation.
3. If they match, the cached entry is current; use it.
4. 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_ephemeral` mounts.** 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:

1. The superblock's policy class field is updated.
2. The superblock's template SD is updated.
3. The superblock's generation counter is incremented.
4. Open file descriptors against files on this mount are unaffected (cached granted masks intact).
5. Cached in-memory state derived from the old generation will be invalidated on first access after the change.
6. Files with stored SDs continue to have those SDs.
7. 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_ephemeral` to `facs_synthesize_persistent` doesn'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](/peios/using-peios/mount-policies/policy-classes.md) — what each settable class does.
- [mount](/peios/using-peios/mount-policies/mount.md) — setting the policy from the command line at attach time.
- [Privileges](/peios/security-fundamentals/privileges/overview.md) — the privilege model behind the SeTcbPrivilege gate.

---

# mount

_Peios / Using Peios / Mount policies_

> Attach a filesystem to the Peios mount tree — new mounts, binds, moves, remounts and propagation changes — and apply a KACS mount policy at mount time.

`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](/peios/using-peios/mount-policies/overview.md)).

```
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](#list-mode)).
- **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 TARGET` pair. Supply both, or use `--source` / `--target`.
- **A lone target** is valid only for `-o remount` and 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](#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](#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](#list-mode)).

## 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=`:

```sh
mount -t stratafs none /bin -o 'strata=/lcl/bin+create:/usr/bin+ro'
```

See [StrataFS](/peios/using-peios/disks-and-filesystems/stratafs/overview.md) 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](#propagation-flags); 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](/peios/using-peios/mount-policies/overview.md) 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](/peios/using-peios/mount-policies/policy-classes.md) for what each class does and [SD storage by filesystem](/peios/using-peios/mount-policies/sd-storage-by-filesystem.md) 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](/peios/using-peios/mount-policies/managing-mounts.md)), 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`](/peios/using-peios/mount-policies/umount.md)).

## 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`](/peios/using-peios/mount-policies/lsblk.md).

## 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_

> Detach a filesystem from the Peios mount tree by mount point or by source, with lazy, forced, recursive and all-targets variants.

`umount` detaches a filesystem from the Peios mount tree. It is the counterpart of [`mount`](/peios/using-peios/mount-policies/mount.md) 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, `umount` refuses with an error naming the candidates — resolve it by naming a specific mount point, or use `-A` to 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` / `--recursive`** unmounts the target and everything mounted underneath it, including any over-mount stack at a single point, ordered deepest-first.
- **`-A` / `--all-targets`** unmounts *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 -R` together** 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`](/peios/using-peios/mount-policies/mount.md) with no arguments or [`lsblk`](/peios/using-peios/mount-policies/lsblk.md).

---

# lsblk

_Peios / Using Peios / Mount policies_

> List block devices as a tree, flat list, JSON or key=value pairs — with filesystem identity, topology, and SD-derived owner and mode columns.

`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`](/peios/using-peios/mount-policies/mount.md): 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/block` unpatched, 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** — `OWNER` and `MODE`, read the same way [`ls -l`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) reads them.
- **The `/dev/disk/by-*` symlink farm** — `ID-LINK`. Populated by the device manager once it has run; there is deliberately no `/run/udev/data` parser, 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`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) 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_

> The tools Peios ships for creating, checking and inspecting ext2/3/4 and FAT filesystems, and why they are packaged from upstream rather than rewritten.

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](/peios/using-peios/disks-and-filesystems/formatting-with-security-descriptors.md) 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](/peios/using-peios/mount-policies/sd-storage-by-filesystem.md).

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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md).

## 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](/peios/using-peios/disks-and-filesystems/partitioning.md).

**Filesystems other than ext2/3/4 and FAT.** The mount side understands XFS, Btrfs and NTFS as well — see [SD storage by filesystem](/peios/using-peios/mount-policies/sd-storage-by-filesystem.md) — 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](/peios/using-peios/disks-and-filesystems/formatting-with-security-descriptors.md).

For the exact command surface, including the extended options and the Peios defaults baked into the binary, read [`mke2fs`](/peios/using-peios/disks-and-filesystems/mke2fs.md).

For how these tools are put to work copying a live system onto a disk that then boots itself, read [Installing to disk](/peios/using-peios/disks-and-filesystems/installing-to-disk.md).

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](/peios/using-peios/disks-and-filesystems/stable-device-names.md).

For what happens to a filesystem once it is attached to the mount tree, read [Mount policies](/peios/using-peios/mount-policies/overview.md).

---

# Stable device names

_Peios / Using Peios / Disks and filesystems_

> Why /dev/vda is not a name to keep, what /dev/disk/by-uuid and its siblings are, who creates them, and where they are not available.

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](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) 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](/peios/using-peios/mount-policies/sd-storage-by-filesystem.md).

## 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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md). 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](/peios/using-peios/disks-and-filesystems/partitioning.md).

For how the installer records the root filesystem's UUID into the kernel command line, read [Installing to disk](/peios/using-peios/disks-and-filesystems/installing-to-disk.md).

---

# Partitioning

_Peios / Using Peios / Disks and filesystems_

> part, the Peios partitioning tool: creating GPTs, adding and removing partitions, and what it refuses to touch.

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`](/peios/using-peios/disks-and-filesystems/mke2fs.md) 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](/peios/using-peios/disks-and-filesystems/formatting-with-security-descriptors.md) and [`mke2fs`](/peios/using-peios/disks-and-filesystems/mke2fs.md).

To have the installer do all of this for you, read [Installing to disk](/peios/using-peios/disks-and-filesystems/installing-to-disk.md) — `peios-install --whole-disk` runs exactly the three commands above before it formats anything.

---

# Formatting with security descriptors

_Peios / Using Peios / Disks and filesystems_

> How a filesystem gets real security descriptors at creation time, rather than synthesised ones at mount time, and why that distinction matters.

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`](/peios/using-peios/mount-policies/policy-classes.md) 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](/peios/security-fundamentals/security-descriptors/overview.md), 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](/peios/security-fundamentals/security-descriptors/inheritance.md).

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`](/peios/using-peios/disks-and-filesystems/mke2fs.md).

For how a descriptor is physically stored once written, including when an oversized one needs its own inode, read [SD storage by filesystem](/peios/using-peios/mount-policies/sd-storage-by-filesystem.md).

For what the mount policy does with the descriptor you stamped — and what happens on a filesystem that has none — read [Policy classes](/peios/using-peios/mount-policies/policy-classes.md).

---

# mke2fs

_Peios / Using Peios / Disks and filesystems_

> The Peios additions to mke2fs: the root_sddl and root_sd_file extended options, security-descriptor-aware population, and the compiled-in Peios defaults.

`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](/peios/security-fundamentals/security-descriptors/inheritance.md) 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:

1. The file named by the `MKE2FS_CONFIG` environment variable, if set.
2. `/etc/mke2fs.conf`, if present.
3. 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](/peios/using-peios/disks-and-filesystems/formatting-with-security-descriptors.md).

For reading and rewriting descriptors on a mounted filesystem, read [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md).

For how the descriptor is stored, and when `ea_inode` becomes load-bearing, read [SD storage by filesystem](/peios/using-peios/mount-policies/sd-storage-by-filesystem.md).

---

# Installing to disk

_Peios / Using Peios / Disks and filesystems_

> How a Peios system is copied from a live medium onto a disk, and how the installed system boots itself afterwards.

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`](/peios/using-peios/disks-and-filesystems/partitioning.md) 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](/peios/using-peios/disks-and-filesystems/formatting-with-security-descriptors.md).

**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.

> [!CAUTION]
> One thing this deliberately does not solve: the account arrives with the password it had, and on an image that shipped a development account that password is in the image and therefore public. Until installation can prompt for a new one, treat a freshly installed system as carrying a known credential and change it — `lps` is the tool — before the machine is anywhere it matters.

## 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](/peios/using-peios/disks-and-filesystems/stable-device-names.md).

## 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](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md) 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`](/peios/using-peios/disks-and-filesystems/partitioning.md) 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](/peios/using-peios/disks-and-filesystems/formatting-with-security-descriptors.md).

For the hook mechanism the two root-mounting hooks share, read [Boot hooks](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md).

For what `deny-missing` does with a file that has no descriptor, and why the live path cannot use it, read [Policy classes](/peios/using-peios/mount-policies/policy-classes.md).

---

# StrataFS

_Peios / Using Peios / Disks and filesystems / StrataFS_

> StrataFS merges ordinary directories by precedence while keeping each stratum independently manageable. Mounting a stack and the stratafs inspection tool.

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`:

```sh
mount -t stratafs none /bin -o 'strata=/lcl/bin+create:/usr/bin+ro'
```

`/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](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md)
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:

```text
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:

```sh
stratafs list
stratafs list /bin
```

Typical output is:

```text
/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:

```sh
stratafs resolve /bin/sh
```

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:

```sh
stratafs origin /bin/sh
```

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:

```sh
stratafs sweep /bin
```

Each result is classified as:

- `gap`: only the create stratum has this path;
- `override`: a lower stratum also has it; or
- `shadowed`: 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:

```sh
stratafs diff /bin/sh
```

`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`](/peios/using-peios/mount-policies/mount.md). For how access to every real object is
decided, start with [File access](/peios/security-fundamentals/file-access/overview.md).

---

# Package management

_Peios / Using Peios / Package management_

> peipkg installs, upgrades, removes, and queries software on a Peios system. This page covers packages, repositories, the security model, and the commands.

`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](/peios/using-peios/package-management/claims.md): 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`](/peios/using-peios/package-management/inspecting-and-verifying.md) 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](/peios/using-peios/package-management/repositories-and-trust.md).

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](/peios/using-peios/package-management/installing-and-removing.md) covers what it keeps and what it gives up.

```mermaid
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](/peios/security-fundamentals/security-descriptors/overview.md) 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](/peios/security-fundamentals/access-decisions/overview.md) 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`](/peios/using-peios/package-management/keeping-a-system-current.md) and [`recover`](/peios/using-peios/package-management/transactions-and-recovery.md) to do it. [Transactions and recovery](/peios/using-peios/package-management/transactions-and-recovery.md) 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](/peios/security-fundamentals/auditing/overview.md). 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](/peios/using-peios/package-management/named-roots.md). Named roots are more than a convenience: they are how peipkg keeps components such as the initramfs current. See [Named roots](/peios/using-peios/package-management/named-roots.md) 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](/peios/using-peios/package-management/composing-a-root.md).

## Where to start

For the everyday work — putting software on a system and taking it off — read [Installing and removing packages](/peios/using-peios/package-management/installing-and-removing.md).

For keeping a system up to date, and for walking a change back, read [Keeping a system current](/peios/using-peios/package-management/keeping-a-system-current.md).

To configure where packages come from and how their authenticity is established, read [Repositories and trust](/peios/using-peios/package-management/repositories-and-trust.md).

To understand why an interrupted install is always safe, read [Transactions and recovery](/peios/using-peios/package-management/transactions-and-recovery.md).

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](/peios/using-peios/package-management/dependency-resolution.md).

To understand how two packages that offer the same shared name settle on a single winner, read [Claims](/peios/using-peios/package-management/claims.md).

To inspect what is installed and check that it is intact, read [Inspecting and verifying](/peios/using-peios/package-management/inspecting-and-verifying.md).

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](/peios/using-peios/package-management/named-roots.md).

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](/peios/using-peios/package-management/composing-a-root.md).

---

# Installing and removing packages

_Peios / Using Peios / Package management_

> install puts packages on the system; remove takes them off. The plan-and-confirm flow, raw installs from a local .peipkg file, and cascade removals.

`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](/peios/using-peios/package-management/dependency-resolution.md); this page is about the flow around it.

### The plan-and-confirm flow

`install`, `remove`, and the commands on [Keeping a system current](/peios/using-peios/package-management/keeping-a-system-current.md) 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](/peios/using-peios/package-management/transactions-and-recovery.md): 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](/peios/using-peios/package-management/repositories-and-trust.md). |
| `--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](/peios/using-peios/package-management/dependency-resolution.md) 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](/peios/using-peios/package-management/claims.md).

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](/peios/using-peios/package-management/named-roots.md) 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_

> refresh learns what is available; upgrade moves to it; downgrade and undo walk a bad change back. The routine update cycle and the recovery commands.

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](/peios/using-peios/package-management/repositories-and-trust.md).

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](/peios/using-peios/package-management/repositories-and-trust.md).

## 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](/peios/using-peios/package-management/named-roots.md) 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](/peios/using-peios/package-management/dependency-resolution.md) 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`](/peios/using-peios/package-management/transactions-and-recovery.md) — 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](/peios/using-peios/package-management/transactions-and-recovery.md), 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_

> The repo commands, the trust ceremony that anchors a repository to a signing key, key rotation, signature policy, priority, and freshness protection.

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](/peios/using-peios/package-management/keeping-a-system-current.md) 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:

1. peipkg fetches the repository's descriptor and its signing keys.
2. It checks the fetched keys against the anchors you supplied — a fingerprint must match bit for bit.
3. 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_anchors` under the `required` policy, because there would be nothing to verify the descriptor against.
- A failed ceremony leaves the `.repo` file 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](/peios/using-peios/package-management/dependency-resolution.md).

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.

```toml
base_url         = "https://pkgs.peios.org"
priority         = 10
signature_policy = "required"
trust_anchors    = ["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](/peios/security-fundamentals/security-descriptors/overview.md) 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 peipkg change is an atomic, reversible transaction. This page covers the three phases, the commit instant, interrupted runs, and recover and history.

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.

```mermaid
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`](/peios/using-peios/package-management/keeping-a-system-current.md) 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](/peios/using-peios/package-management/named-roots.md) 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`](/peios/using-peios/package-management/keeping-a-system-current.md) 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-run` is 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 `undo` for a recent one or `downgrade` for 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](/peios/using-peios/package-management/keeping-a-system-current.md).

For how a transaction that spans several named roots commits and recovers as a unit, read [Named roots](/peios/using-peios/package-management/named-roots.md).

For the events every transaction emits, read [Auditing](/peios/security-fundamentals/auditing/overview.md).

---

# Dependency resolution

_Peios / Using Peios / Package management_

> How a request becomes a concrete plan — dependencies, conflicts, provides and replaces, version choice, and the elevated-authorisation prompt.

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](/peios/using-peios/package-management/claims.md).

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](/peios/using-peios/package-management/named-roots.md).

## Choosing a version

When more than one version of a package could satisfy the plan, peipkg chooses by a fixed rule:

1. Prefer the highest version that satisfies every constraint on that package.
2. When two repositories offer the same version, prefer the one with the stronger (lower-numbered) **priority** — see [Repositories and trust](/peios/using-peios/package-management/repositories-and-trust.md).

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](/peios/using-peios/package-management/installing-and-removing.md).

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:

- **`--yes` does not satisfy it.** `--yes` skips the routine `proceed?` 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](/peios/security-fundamentals/auditing/overview.md) — 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](/peios/using-peios/package-management/installing-and-removing.md).

For the single-holder extension of provides, read [Claims](/peios/using-peios/package-management/claims.md).

For the repository priorities and trust states resolution consults, read [Repositories and trust](/peios/using-peios/package-management/repositories-and-trust.md).

---

# Claims

_Peios / Using Peios / Package management_

> A claim is a shared filesystem name that only one installed package may hold. How claims materialise as symlinks, and the claim command for reassigning them.

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](/peios/security-fundamentals/identity/claims.md) — 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](/peios/using-peios/package-management/dependency-resolution.md). 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-all` together with `--claim`.
- `--claim-all` together 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`](/peios/using-peios/package-management/transactions-and-recovery.md), can be reversed with [`undo`](/peios/using-peios/package-management/keeping-a-system-current.md), and are fully auditable — each emits a `claim` event to the [audit stream](/peios/security-fundamentals/auditing/overview.md). 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 read-only commands — list, info, files, owns, and search to see what is installed and available, verify to check files are intact, clean to tidy the cache.

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`](/peios/using-peios/package-management/keeping-a-system-current.md) 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_

> peipkg can operate on several roots at once — named references, dotted composition, default_root, the cascading upgrade, and cross-root dependencies.

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`](/peios/using-peios/package-management/overview.md) 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 `--root` was given.** Passing `--root` with any value, including a named one, states the target outright and suppresses `default_root` re-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](/peios/using-peios/package-management/transactions-and-recovery.md); 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](/peios/using-peios/package-management/composing-a-root.md).

`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](/peios/using-peios/package-management/keeping-a-system-current.md) 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](/peios/using-peios/package-management/transactions-and-recovery.md) 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-compose builds a fresh, package-owned root from a declarative manifest — offline and deterministically. The lock and build verbs and the lock file.

`peipkg` mutates a running system in place: every change is an atomic, reversible [transaction](/peios/using-peios/package-management/transactions-and-recovery.md) 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.

```toml
schema = 1
arch = "x86_64"
source_date = "2026-07-01T00:00:00Z"

local_packages = ["./bootstrap/*.peipkg"]

[[repository]]
name = "core"
base_url = "https://repo.example.org/core"
priority = 10
signature_policy = "required"
trust_anchors = ["a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"]

[[root]]
name = "initramfs"
path = "boot/initramfs"

[[package]]
name = "base-system"

[[package]]
name = "nginx"
version = ">=1.27, <1.28"
repository = "core"

[[package]]
name = "live-boot"
root = "initramfs"
```

### 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](/peios/using-peios/package-management/repositories-and-trust.md). 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`](/peios/using-peios/package-management/named-roots.md). 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](/peios/using-peios/package-management/named-roots.md) 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 --locked`** requires the lock to already exist and does no resolution at all — the reproducible, air-gapped rebuild.
- **`build --update`** re-resolves from scratch and rewrites the lock.

An unattended compose run cannot authorise the elevated actions that the live consumer would stop and prompt for. Rather than fail the build, compose surfaces those as stderr warnings. See [Elevated authorisation](/peios/using-peios/package-management/dependency-resolution.md) 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](/peios/using-peios/package-management/named-roots.md) 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](/peios/using-peios/package-management/claims.md) 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 `.peipkg` files and it does not serve repositories — those are the separate `peipkg-build`, `peipkg-repo`, and `peipkg-manager` tools. Compose consumes ordinary peipkg packages and repositories unchanged, exactly as [PSPU §5](/peios/advanced-peios/pspu/package-format-and-repository-protocol/scope-and-roles.md) defines them.
- **No side effects.** `ldconfig`, `depmod`, and `man-db` are 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 system's typed, access-controlled, watchable configuration store — a tree of keys and values reached only through kernel system calls.

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](/peios/security-fundamentals/file-access/overview.md) 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](/peios/security-fundamentals/security-descriptors/overview.md). 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. |

```mermaid
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\&lt;SID&gt;\ (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`](/peios/using-peios/registry-administration/regman.md), 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](/peios/using-peios/registry-layers/layers.md) 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](/peios/using-peios/registry-administration/lcs-and-sources.md), 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](/peios/using-peios/registry-concepts/keys-values-and-types.md).

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](/peios/using-peios/registry-concepts/configuration-and-meaning.md).

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](/peios/using-peios/registry-layers/layers.md).

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](/peios/using-peios/registry-security/access-control.md).

If you want to know how a service reacts to a configuration change instead of polling for it, read [Watching for changes](/peios/using-peios/registry-concepts/watches.md).

For the two command-line tools, read [`regman`](/peios/using-peios/registry-administration/regman.md) — the registry's manual, which tells you what a key *means* — and [`reg`](/peios/using-peios/registry-administration/reg.md) — 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 — a tree of keys holding named typed values, the value types, the naming rules, and why a value is typed but never interpreted.

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](/peios/using-peios/registry-security/access-control.md); 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](/peios/using-peios/registry-advanced/registry-links.md). |
| `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](/peios/using-peios/registry-concepts/configuration-and-meaning.md).

## 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](/peios/using-peios/registry-concepts/configuration-and-meaning.md).

If you want the layered truth beneath the single value you read — precedence, the base layer, and automatic revert — read [Layers](/peios/using-peios/registry-layers/layers.md).

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](/peios/using-peios/registry-security/access-control.md).

---

# Configuration, not storage

_Peios / Using Peios / Concepts_

> The registry stores values it does not understand — meaning lives in the owning subsystem and in regman, and a stored value can differ from the one in use.

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](/peios/using-peios/registry-security/access-control.md) — but it never asks what the value *means*. As [Keys, values, and types](/peios/using-peios/registry-concepts/keys-values-and-types.md) 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`](/peios/using-peios/registry-administration/regman.md), 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.

```mermaid
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](/peios/security-fundamentals/auditing/overview.md) 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](/peios/using-peios/registry-administration/bootstrap-and-self-configuration.md).

## 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](/peios/using-peios/registry-concepts/watches.md).

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](/peios/using-peios/registry-administration/bootstrap-and-self-configuration.md).

If you are ready for the layered model beneath the single value you read, read [Layers](/peios/using-peios/registry-layers/layers.md).

---

# Watching for changes

_Peios / Using Peios / Concepts_

> A persistent watch on a key or subtree reports changes to effective state — best-effort delivery, so a watcher must be ready to re-read.

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](/peios/using-peios/registry-layers/layers.md). 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](/peios/using-peios/registry-administration/lcs-and-sources.md) 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](/peios/using-peios/registry-concepts/configuration-and-meaning.md) 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](/peios/using-peios/registry-administration/bootstrap-and-self-configuration.md)). 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](/peios/using-peios/registry-administration/bootstrap-and-self-configuration.md).

If you want the validation half of the reaction loop — why a changed value might be read and then refused — read [Configuration, not storage](/peios/using-peios/registry-concepts/configuration-and-meaning.md).

If you want what it takes to be allowed to watch a key, read [Access control on keys](/peios/using-peios/registry-security/access-control.md).

---

# Layers

_Peios / Using Peios / Layers_

> A layer is a label on a write, not a position in a stack — most layers share one precedence, and the winner is chosen per value by recency.

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.

```mermaid
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](/peios/using-peios/registry-security/access-control.md).)

## "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](/peios/using-peios/registry-layers/what-layers-are-for.md): 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](/peios/using-peios/registry-security/access-control.md). 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](/peios/using-peios/registry-concepts/watches.md).

---

# What layers are for

_Peios / Using Peios / Layers_

> Layering makes configuration removable — delete a layer and its writes vanish, the next-best write resurfaces, and nothing is left behind.

[Layers](/peios/using-peios/registry-layers/layers.md) 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](/peios/using-peios/registry-layers/layers.md)); 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.

```mermaid
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](/peios/using-peios/registry-layers/layers.md) 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](/peios/using-peios/registry-security/access-control.md).)

## 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](/peios/using-peios/registry-security/access-control.md) 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](/peios/using-peios/registry-security/access-control.md) 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](/peios/using-peios/registry-layers/deleting-keys-and-values.md).

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](/peios/using-peios/registry-security/access-control.md).

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](/peios/using-peios/registry-concepts/watches.md).

For the sandboxing case — per-thread *private* layers that only one caller sees — read [Private hives and layers](/peios/using-peios/registry-advanced/private-hives-and-layers.md).

---

# Deleting keys and values

_Peios / Using Peios / Layers_

> Deleting a key withdraws one layer's claim to a name — the key stays if another layer still claims it, and there is no recursive delete.

Deletion is one more thing the [layered model](/peios/using-peios/registry-layers/layers.md) 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](/peios/using-peios/registry-layers/what-layers-are-for.md) 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](/peios/using-peios/registry-layers/layers.md) 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](/peios/using-peios/registry-layers/what-layers-are-for.md)) 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](/peios/using-peios/registry-layers/what-layers-are-for.md). 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](/peios/using-peios/registry-security/access-control.md). 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](/peios/using-peios/registry-layers/layers.md).

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](/peios/using-peios/registry-layers/what-layers-are-for.md).

---

# Access control on keys

_Peios / Using Peios / Security_

> Every key is a secured object under the same AccessCheck as everything else — checked once at open, per key not per value, with no traversal check.

Every registry key is a secured object. It carries a [security descriptor](/peios/security-fundamentals/security-descriptors/overview.md) — 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](/peios/security-fundamentals/access-decisions/overview.md) 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](/peios/security-fundamentals/file-access/the-handle-model.md) and [central access policies](/peios/security-fundamentals/central-access-policies/overview.md) 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](/peios/using-peios/registry-concepts/watches.md). |
| `KEY_CREATE_LINK` | Creating a [link key](/peios/using-peios/registry-advanced/registry-links.md) (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](/peios/using-peios/registry-layers/layers.md)), creating a link, or bulk [backup and restore](/peios/using-peios/registry-administration/lcs-and-sources.md). 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](/peios/security-fundamentals/security-descriptors/inheritance.md) 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](/peios/using-peios/registry-concepts/watches.md) 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](/peios/using-peios/registry-layers/what-layers-are-for.md) 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](/peios/using-peios/registry-concepts/watches.md).

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](/peios/using-peios/registry-administration/lcs-and-sources.md).

For the broader access model these rights plug into — the AccessCheck pipeline itself — read [Access decisions](/peios/security-fundamentals/access-decisions/overview.md).

---

# Default security descriptors

_Peios / Using Peios / Security_

> Where a component keeps the security descriptors it stamps at runtime — the SdDefaults convention — and the reject-or-keep rule that guards it.

Some software stamps [security descriptors](/peios/security-fundamentals/security-descriptors/overview.md) 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.
- `SdDefaults` is 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](/peios/using-peios/registry-concepts/configuration-and-meaning.md) 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](/peios/security-fundamentals/security-descriptors/inheritance.md) 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`](/peios/using-peios/registry-administration/regman.md) 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](/peios/using-peios/registry-security/access-control.md). 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](/peios/advanced-peios/pspu/package-format-and-repository-protocol/security-descriptor-overrides.md). 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:

1. 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 `SdDefaults` key has never been created.
2. Name each value for the object it protects, not for its content — `SpoolDirectory`, not `SystemOnlyOici`.
3. 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.
4. Document every name in `regman`, including an `Applies` line 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](/peios/using-peios/registry-concepts/configuration-and-meaning.md).

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](/peios/security-fundamentals/security-descriptors/overview.md) and [Inheritance](/peios/security-fundamentals/security-descriptors/inheritance.md).

For protecting the `SdDefaults` key itself, read [Access control on keys](/peios/using-peios/registry-security/access-control.md).

---

# How the registry boots and configures itself

_Peios / Using Peios / Administration_

> The registry runs on compiled-in defaults from the instant the kernel loads and hot-swaps to registry-backed configuration once its store is up.

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](/peios/using-peios/registry-concepts/configuration-and-meaning.md) 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](/peios/using-peios/registry-layers/what-layers-are-for.md) 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.

```mermaid
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](/peios/using-peios/registry-concepts/configuration-and-meaning.md): 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:

1. The store — `registryd`, the base registry source [peinit](/peios/using-peios/services-and-jobs/overview.md) starts at early boot (see [LCS and sources](/peios/using-peios/registry-administration/lcs-and-sources.md)) — starts, finds its database empty, and creates the hive root keys with their default [security descriptors](/peios/using-peios/registry-security/access-control.md) — but no configuration beneath them.
2. The registry looks for its parameter keys, finds nothing, and keeps its compiled-in defaults. It is fully operational regardless.
3. The init system (not the registry's concern) restores a **seed** — a [backup](/peios/using-peios/registry-administration/backup-and-restore.md) of the system's initial configuration — that populates `Machine\` with the system's real configuration.
4. 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](/peios/using-peios/registry-concepts/watches.md) 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](/peios/using-peios/registry-concepts/watches.md) 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](/peios/using-peios/registry-administration/lcs-and-sources.md).

If you want the validation discipline this page leans on, read [Configuration, not storage](/peios/using-peios/registry-concepts/configuration-and-meaning.md).

If you want the change-notification mechanism behind the self-watch, read [Watching for changes](/peios/using-peios/registry-concepts/watches.md).

---

# Backup and restore

_Peios / Using Peios / Administration_

> Export a subtree to a stream and restore one back wholesale — how the system is seeded, recovered, and migrated. Restore is a replace, not a merge.

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](/peios/using-peios/registry-administration/bootstrap-and-self-configuration.md) — 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](/peios/using-peios/registry-layers/layers.md)**: 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.

> [!WARNING]
> **The privilege to restore is, in effect, the privilege to rewrite security on everything in the target subtree.** A backup carries each key's security descriptor, and restore writes those descriptors back — so restoring lets the caller replace owners and permissions throughout the subtree, which is close to taking ownership of all of it. Granting restore privilege is therefore a serious act, not a routine one: read it as "may rewrite this subtree, security and all," because that is what it grants.

(Both operations are audited every time they run, regardless of a key's own audit settings — see the [auditing topic](/peios/security-fundamentals/auditing/overview.md).)

## 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](/peios/using-peios/registry-administration/bootstrap-and-self-configuration.md).
- **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](/peios/using-peios/registry-administration/bootstrap-and-self-configuration.md).

For who actually performs these operations — the kernel coordinating the store — read [LCS and sources](/peios/using-peios/registry-administration/lcs-and-sources.md).

For the privilege model they lean on, read [Access control on keys](/peios/using-peios/registry-security/access-control.md).

---

# LCS and sources

_Peios / Using Peios / Administration_

> Underneath, the registry is two components — LCS, the kernel authority, and userspace sources that only persist data, with loregd as the default.

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](/peios/using-peios/registry-security/access-control.md) — running AccessCheck | No security decisions at all |
| [Layer resolution](/peios/using-peios/registry-layers/layers.md) — choosing the effective value | No idea which write is effective |
| [Watch](/peios/using-peios/registry-concepts/watches.md) 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.

```mermaid
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](/peios/using-peios/services-and-jobs/overview.md) 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](/peios/developing-for-peios/registry-sources/overview.md) 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](/peios/using-peios/registry-advanced/transactions.md), and [backup and restore](/peios/using-peios/registry-administration/backup-and-restore.md). 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](/peios/using-peios/registry-concepts/watches.md) 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)](/peios/using-peios/registry-administration/regman.md).

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](/peios/using-peios/registry-advanced/transactions.md).

For complete per-caller isolation — hives and layers visible only to one sandboxed process — read [Private hives and layers](/peios/using-peios/registry-advanced/private-hives-and-layers.md).

For keys that point at other keys — the one place the registry follows a value — read [Registry links](/peios/using-peios/registry-advanced/registry-links.md).

---

# The registry manual (regman)

_Peios / Using Peios / Administration_

> regman documents what a registry key or value means — type, default, valid range, and when a change applies — from shipped docs, never the live registry.

[Configuration, not storage](/peios/using-peios/registry-concepts/configuration-and-meaning.md) 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](/peios/using-peios/registry-administration/lcs-and-sources.md) 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`](/peios/using-peios/registry-administration/reg.md)'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](/peios/using-peios/registry-concepts/configuration-and-meaning.md) 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`](/peios/using-peios/registry-administration/reg.md) — a live query against the store |
| What the subsystem is *actually running on* | the [event log](/peios/security-fundamentals/auditing/overview.md) |

`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](/peios/using-peios/registry-concepts/configuration-and-meaning.md): `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](/peios/using-peios/registry-concepts/configuration-and-meaning.md).

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](/peios/using-peios/registry-administration/lcs-and-sources.md).

---

# reg

_Peios / Using Peios / Administration_

> The reg command reads and writes the live registry — values, keys, layers, links, security descriptors, watches, and atomic batches.

`reg` is the command-line interface to the **live** registry — the running [LCS](/peios/using-peios/registry-administration/lcs-and-sources.md) store, reached through the registry system calls. Where [`regman`](/peios/using-peios/registry-administration/regman.md) 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`](/peios/using-peios/registry-administration/regman.md) 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`](/peios/using-peios/registry-administration/regman.md) |

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](/peios/using-peios/registry-security/access-control.md) 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 `.reg` convention: `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](/peios/using-peios/registry-layers/layers.md) 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](#value-literals-and-types) 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](/peios/using-peios/registry-layers/deleting-keys-and-values.md) 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](/peios/using-peios/registry-layers/layers.md) 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](/peios/using-peios/registry-advanced/private-hives-and-layers.md).

---

## Security descriptors

### `reg sd <key>`

Show or set a key's [security descriptor](/peios/security-fundamentals/security-descriptors/overview.md) as SDDL, using the same codec as the [`sd`](/peios/security-fundamentals/security-descriptors/overview.md) 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](/peios/using-peios/registry-security/access-control.md).

---

## 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](/peios/using-peios/registry-advanced/registry-links.md).

---

## 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](/peios/using-peios/registry-concepts/watches.md) 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](/peios/using-peios/registry-advanced/transactions.md) — 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](/peios/using-peios/registry-administration/backup-and-restore.md) 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`](/peios/using-peios/registry-administration/regman.md) — the registry *manual*: what a key or value *means*, as opposed to what it is set to. Consult it before you `reg set`.
- [Keys, values, and types](/peios/using-peios/registry-concepts/keys-values-and-types.md) — the data model `reg` addresses.
- [Layers](/peios/using-peios/registry-layers/layers.md) — what "effective" resolves against, and what `--layer`, `mask`, and `hide` operate on.
- [Access control on keys](/peios/using-peios/registry-security/access-control.md) — the check every `reg` operation goes through.

---

# Private hives and layers

_Peios / Using Peios / Advanced_

> Private hives and layers give one caller its own view of the registry — the building block for sandboxing, with authorisation deferred to KACS.

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](/peios/using-peios/registry-layers/layers.md); 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](/peios/using-peios/registry-layers/layers.md) 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](/peios/using-peios/registry-layers/layers.md).

For where private hives are routed and served, read [LCS and sources](/peios/using-peios/registry-administration/lcs-and-sources.md).

---

# Transactions

_Peios / Using Peios / Advanced_

> A transaction groups several registry writes into one all-or-nothing commit — how a role installs without ever being half-applied.

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](/peios/using-peios/registry-layers/what-layers-are-for.md) 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](/peios/using-peios/registry-layers/what-layers-are-for.md).)

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](/peios/using-peios/registry-administration/lcs-and-sources.md). 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](/peios/using-peios/registry-layers/layers.md) 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](/peios/using-peios/registry-layers/layers.md). 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](/peios/using-peios/registry-layers/what-layers-are-for.md).

For the recency rule that settles competing writes, read [Layers](/peios/using-peios/registry-layers/layers.md).

---

# Registry links

_Peios / Using Peios / Advanced_

> A key can be a symbolic link to another key — the one place the registry interprets a value — with layered targets and privileged creation.

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](/peios/using-peios/registry-concepts/keys-values-and-types.md) 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](/peios/using-peios/registry-layers/layers.md) 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](/peios/using-peios/registry-concepts/keys-values-and-types.md).

For how a layer can redirect or break a link, read [Layers](/peios/using-peios/registry-layers/layers.md).

---

# Files and directories

_Peios / Using Peios / Peiosutils / Files and directories_

> The commands that create, copy, move, remove, and resize files and directories — and how each one handles a file's security descriptor.

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`](/peios/using-peios/peiosutils/files-and-directories/cp.md) | Copy files and directories. |
| [`mv`](/peios/using-peios/peiosutils/files-and-directories/mv.md) | Move or rename files and directories. |
| [`rm`](/peios/using-peios/peiosutils/files-and-directories/rm.md) | Remove files, and with `-r`, directories. |
| [`rmdir`](/peios/using-peios/peiosutils/files-and-directories/rmdir.md) | Remove directories, but only empty ones. |
| [`mkdir`](/peios/using-peios/peiosutils/files-and-directories/mkdir.md) | Create directories. |
| [`mkfifo`](/peios/using-peios/peiosutils/files-and-directories/mkfifo.md) | Create named pipes (FIFOs). |
| [`mknod`](/peios/using-peios/peiosutils/files-and-directories/mknod.md) | Create special files — device nodes and FIFOs. |
| [`touch`](/peios/using-peios/peiosutils/files-and-directories/touch.md) | Create empty files, or update a file's timestamps. |
| [`ln`](/peios/using-peios/peiosutils/files-and-directories/ln.md) | Create hard links and symbolic links. |
| [`link` and `unlink`](/peios/using-peios/peiosutils/files-and-directories/link-and-unlink.md) | The single-purpose, low-level versions of "make a hard link" and "remove a file". |
| [`shred`](/peios/using-peios/peiosutils/files-and-directories/shred.md) | Destroy a file's contents by overwriting them, so they cannot be recovered. |
| [`dd`](/peios/using-peios/peiosutils/files-and-directories/dd.md) | Copy data block by block, converting it on the way. |
| [`df`](/peios/using-peios/peiosutils/files-and-directories/df.md) | Report how much space each file system has, and how much is free. |
| [`du`](/peios/using-peios/peiosutils/files-and-directories/du.md) | Report how much space files and directories use. |
| [`truncate`](/peios/using-peios/peiosutils/files-and-directories/truncate.md) | Shrink or extend a file to an exact size. |
| [`mktemp`](/peios/using-peios/peiosutils/files-and-directories/mktemp.md) | 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](/peios/security-fundamentals/security-descriptors/overview.md) 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`](/peios/using-peios/peiosutils/files-and-directories/mkdir.md) 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`](/peios/using-peios/peiosutils/files-and-directories/cp.md) 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`](/peios/using-peios/peiosutils/files-and-directories/cp.md).

For creating files and directories, and the shared creation flags, read [`mkdir`](/peios/using-peios/peiosutils/files-and-directories/mkdir.md).

For removing things safely, read [`rm`](/peios/using-peios/peiosutils/files-and-directories/rm.md).

---

# cp

_Peios / Using Peios / Peiosutils / Files and directories_

> Copy files and directories — including how a copy gets its security descriptor and the --preserve family that carries attributes from the source.

`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](/peios/security-fundamentals/security-descriptors/overview.md) — 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.

- **`--sd`** copies 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-explicit`** copies 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](/peios/security-fundamentals/security-descriptors/inheritance.md).

### 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_

> Move or rename files and directories — including how a move handles a file's security descriptor depending on whether it crosses a file system.

`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`](/peios/using-peios/peiosutils/files-and-directories/cp.md), 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`](/peios/using-peios/peiosutils/files-and-directories/cp.md) 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_

> Remove files and directories — including how rm decides a file is write-protected and worth prompting about.

`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](/peios/security-fundamentals/security-descriptors/overview.md). 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`](/peios/using-peios/peiosutils/files-and-directories/shred.md), 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_

> Remove directories, but only when they are already empty.

`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`](/peios/using-peios/peiosutils/files-and-directories/rm.md).

## 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_

> Create directories — and the shared creation flags that set a new object's security descriptor, used by mkdir, mkfifo, mknod, and touch.

`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](/peios/security-fundamentals/security-descriptors/overview.md) — 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`](/peios/using-peios/peiosutils/files-and-directories/mkfifo.md), [`mknod`](/peios/using-peios/peiosutils/files-and-directories/mknod.md), and [`touch`](/peios/using-peios/peiosutils/files-and-directories/touch.md) — 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](/peios/security-fundamentals/security-descriptors/inheritance.md) 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_

> Create named pipes (FIFOs).

`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](/peios/security-fundamentals/security-descriptors/overview.md). 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`](/peios/using-peios/peiosutils/files-and-directories/mkdir.md) 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_

> Create special files — device nodes and named pipes.

`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`](/peios/using-peios/peiosutils/files-and-directories/mkfifo.md) 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](/peios/security-fundamentals/security-descriptors/overview.md) 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`](/peios/using-peios/peiosutils/files-and-directories/mkdir.md) 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_

> Create empty files, or update the timestamps of existing ones.

`touch` does one of two things, depending on whether the file already exists:

- if the file **does not exist**, `touch` creates it, empty;
- if the file **does exist**, `touch` updates 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](/peios/security-fundamentals/security-descriptors/overview.md), 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`](/peios/using-peios/peiosutils/files-and-directories/mkdir.md) 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_

> Create hard links and symbolic links between files.

`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](/peios/security-fundamentals/security-descriptors/overview.md) — 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 named `link_name`.
- `ln target` — create a link to `target` in the current directory, with the same final name.
- `ln target... directory` — create a link to each `target` inside `directory`.
- `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`](/peios/using-peios/peiosutils/listing-and-paths/readlink.md) prints where a symbolic link points. The low-level [`link`](/peios/using-peios/peiosutils/files-and-directories/link-and-unlink.md) 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_

> The single-purpose, low-level commands for creating one hard link and for removing one file.

`link` and `unlink` are the bare, single-purpose versions of two operations that [`ln`](/peios/using-peios/peiosutils/files-and-directories/ln.md) and [`rm`](/peios/using-peios/peiosutils/files-and-directories/rm.md) 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`](/peios/using-peios/peiosutils/files-and-directories/ln.md).

## `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`](/peios/using-peios/peiosutils/files-and-directories/rm.md).

## 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_

> Destroy a file's contents by overwriting them repeatedly, so they cannot be recovered — including how --force overrides a file's protection.

`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`](/peios/using-peios/peiosutils/files-and-directories/rm.md) 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](/peios/security-fundamentals/security-descriptors/overview.md) 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_

> Copy data block by block between files, optionally converting and reshaping it on the way.

`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](/peios/security-fundamentals/security-descriptors/overview.md), 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_

> Report how much space each file system has and how much of it is free.

`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_

> Report how much disk space files and directories use.

`du` — "disk usage" — reports how much space files and directories occupy. Where [`df`](/peios/using-peios/peiosutils/files-and-directories/df.md) 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_

> Shrink or extend a file to an exact size.

`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](/peios/security-fundamentals/security-descriptors/overview.md), 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_

> Create a temporary file or directory with a safely unique name.

`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](/peios/security-fundamentals/security-descriptors/overview.md); 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 `X`s 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 `X`s.

## 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_

> The sd command reads and changes a file's security descriptor — owner, access rules, audit rules, integrity label, and inheritance.

`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`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) shows a file's owner and a summary, and [`cp --preserve`](/peios/using-peios/peiosutils/files-and-directories/cp.md) 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](/peios/security-fundamentals/identity/sids.md) 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](/peios/security-fundamentals/access-decisions/overview.md): "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](/peios/security-fundamentals/security-descriptors/conditional-aces.md), 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](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md).

### `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](/peios/security-fundamentals/security-descriptors/the-sacl.md). `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](/peios/security-fundamentals/security-descriptors/ownership.md).

## 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](/peios/security-fundamentals/access-decisions/mandatory-integrity-control.md).

## 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](/peios/security-fundamentals/security-descriptors/inheritance.md) 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_

> The commands that show you what a directory contains and that inspect, resolve, and manipulate path names — ls, stat, pwd, readlink, realpath, and the rest.

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`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) | List the contents of a directory. The everyday command for "what's in here?". |
| [`dir` and `vdir`](/peios/using-peios/peiosutils/listing-and-paths/dir-and-vdir.md) | `ls` with a fixed output style. `dir` lists in columns; `vdir` lists in long format. |
| [`stat`](/peios/using-peios/peiosutils/listing-and-paths/stat.md) | Show the detailed status of one file — its type, size, timestamps, and raw inode metadata. |
| [`pwd`](/peios/using-peios/peiosutils/listing-and-paths/pwd.md) | Print the directory you are currently working in. |
| [`readlink`](/peios/using-peios/peiosutils/listing-and-paths/readlink.md) | Print where a symbolic link points. |
| [`realpath`](/peios/using-peios/peiosutils/listing-and-paths/realpath.md) | Resolve a path to its canonical, absolute form — every symlink followed, every `.` and `..` removed. |
| [`basename`](/peios/using-peios/peiosutils/listing-and-paths/basename.md) | Strip the directory part off a path, leaving just the final name. |
| [`dirname`](/peios/using-peios/peiosutils/listing-and-paths/dirname.md) | The opposite of `basename` — strip the final name, leaving the directory. |
| [`pathchk`](/peios/using-peios/peiosutils/listing-and-paths/pathchk.md) | Check whether a path name is valid and portable before you try to create it. |
| [`dircolors`](/peios/using-peios/peiosutils/listing-and-paths/dircolors.md) | 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 -l` surfaces 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.
- **`stat` is 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. `stat` reports 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](/peios/security-fundamentals/security-descriptors/overview.md).

## Where to start

If you want the everyday "what's in this directory" command and the meaning of every column it prints, read [`ls`](/peios/using-peios/peiosutils/listing-and-paths/ls.md).

If you need the full detail of one specific file, read [`stat`](/peios/using-peios/peiosutils/listing-and-paths/stat.md).

If you are writing a script and need to take a path apart or canonicalise it, the string commands — [`basename`](/peios/using-peios/peiosutils/listing-and-paths/basename.md), [`dirname`](/peios/using-peios/peiosutils/listing-and-paths/dirname.md), [`realpath`](/peios/using-peios/peiosutils/listing-and-paths/realpath.md) — are what you want.

---

# ls

_Peios / Using Peios / Peiosutils / Listing and paths_

> List the contents of a directory — the default listing, the Peios long format and its security-descriptor columns, and the sorting and formatting options.

`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. `ls` leaves hidden entries out unless you ask for them with [`-a` or `-A`](#choosing-what-to-list).
- **Entries are sorted by name.** Alphabetical order, case-sensitive. Other [sort orders](#sorting) are available.
- **The layout adapts to where output goes.** When `ls` is 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](/peios/security-fundamentals/access-decisions/overview.md) 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](/peios/security-fundamentals/security-descriptors/inheritance.md).

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`](/peios/using-peios/peiosutils/listing-and-paths/stat.md) or the dedicated security tooling. The concepts are in [Security descriptors](/peios/security-fundamentals/security-descriptors/overview.md).

### 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](/peios/security-fundamentals/identity/sids.md). For what "owner" means and what it grants, see [Ownership](/peios/security-fundamentals/security-descriptors/ownership.md).

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`](/peios/using-peios/peiosutils/listing-and-paths/dircolors.md) 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`](/peios/using-peios/peiosutils/listing-and-paths/readlink.md) and [`realpath`](/peios/using-peios/peiosutils/listing-and-paths/realpath.md) 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_

> Two commands that list directory contents with a fixed output style — dir always lists in columns, vdir always lists in long format.

`dir` and `vdir` list the contents of a directory. They are [`ls`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) 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`](/peios/using-peios/peiosutils/listing-and-paths/ls.md). 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_

> Display the detailed status of a file or a file system — type, size, timestamps, and raw inode metadata — with an optional custom output format.

`stat` displays the detailed status of a file. Where [`ls`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) 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](/peios/security-fundamentals/security-descriptors/overview.md). 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`](/peios/using-peios/peiosutils/listing-and-paths/ls.md), 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_

> Print the absolute path of the directory you are currently working in.

`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_

> Print the target of a symbolic link, or canonicalise a path by resolving every link in it.

`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`](/peios/using-peios/peiosutils/listing-and-paths/realpath.md) 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_

> Resolve a path to its canonical, absolute form — every symbolic link followed and every . and .. component removed.

`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`](/peios/using-peios/peiosutils/listing-and-paths/readlink.md) 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_

> Strip the directory part off a path, leaving just the final name — and optionally remove a trailing suffix.

`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_

> Strip the final component off a path, leaving the directory part.

`dirname` takes a path and prints everything *except* its final component — the directory the name lives in. It is the counterpart of [`basename`](/peios/using-peios/peiosutils/listing-and-paths/basename.md).

```
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, so `dirname` prints `.` — 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_

> Check whether path names are valid and usable before you try to create the files they name.

`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_

> Produce the shell commands that set LS_COLORS — the variable that controls how ls colours its output.

When [`ls`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) 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_

> The commands that print files to the screen, show parts of them, and combine or split text by line and by column.

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](/peios/using-peios/peiosutils/transforming-text/overview.md), covers the commands that *reshape* text — sorting, de-duplicating, substituting characters.

## The commands

**Viewing a whole file**

| Command | Purpose |
|---|---|
| [`cat`](/peios/using-peios/peiosutils/viewing-and-joining-text/cat.md) | Print one or more files straight through — and join several into one. |
| [`tac`](/peios/using-peios/peiosutils/viewing-and-joining-text/tac.md) | Print a file with its lines in reverse — last line first. |
| [`more`](/peios/using-peios/peiosutils/viewing-and-joining-text/more.md) | Show a file one screen at a time, pausing between screens. |
| [`od`](/peios/using-peios/peiosutils/viewing-and-joining-text/od.md) | Dump a file's raw bytes in octal, hex, decimal, or character form. |

**Viewing part of a file**

| Command | Purpose |
|---|---|
| [`head`](/peios/using-peios/peiosutils/viewing-and-joining-text/head.md) | Print the first lines (or bytes) of a file. |
| [`tail`](/peios/using-peios/peiosutils/viewing-and-joining-text/tail.md) | Print the last lines of a file — and, with `-f`, keep printing as the file grows. |
| [`nl`](/peios/using-peios/peiosutils/viewing-and-joining-text/nl.md) | Print a file with its lines numbered. |

**Cutting and combining**

| Command | Purpose |
|---|---|
| [`cut`](/peios/using-peios/peiosutils/viewing-and-joining-text/cut.md) | Pull selected columns — byte ranges or delimited fields — out of each line. |
| [`paste`](/peios/using-peios/peiosutils/viewing-and-joining-text/paste.md) | Join files side by side, line for line. |
| [`join`](/peios/using-peios/peiosutils/viewing-and-joining-text/join.md) | Join two files on a shared field, like a database join. |
| [`comm`](/peios/using-peios/peiosutils/viewing-and-joining-text/comm.md) | Compare two sorted files and report which lines they share. |

**Splitting a file into files**

| Command | Purpose |
|---|---|
| [`split`](/peios/using-peios/peiosutils/viewing-and-joining-text/split.md) | Break a file into equal-sized pieces. |
| [`csplit`](/peios/using-peios/peiosutils/viewing-and-joining-text/csplit.md) | 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`](/peios/using-peios/peiosutils/viewing-and-joining-text/cat.md) — the most-used command in the topic.

---

# cat

_Peios / Using Peios / Peiosutils / Viewing and joining text_

> Print files to standard output, and concatenate several files into one stream.

`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_

> Print files with their lines in reverse order — last line first.

`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_

> Print the first lines or bytes of a file.

`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_

> Print the last lines of a file — and keep printing as the file grows.

`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`](/peios/using-peios/peiosutils/viewing-and-joining-text/head.md): 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_

> View a file one screen at a time, pausing between screens.

`more` displays a file one screenful at a time. Where [`cat`](/peios/using-peios/peiosutils/viewing-and-joining-text/cat.md) 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_

> Print files with their lines numbered, with control over which lines are counted and how the number is formatted.

`nl` prints a file with its lines numbered. [`cat -n`](/peios/using-peios/peiosutils/viewing-and-joining-text/cat.md) 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_

> Pull selected columns — byte ranges or delimited fields — out of each line of input.

`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_

> Join files side by side, line for line.

`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`](/peios/using-peios/peiosutils/viewing-and-joining-text/cat.md), 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`](/peios/using-peios/peiosutils/viewing-and-joining-text/join.md).

## 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 two files on a shared field, pairing lines that have the same key.

`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`](/peios/using-peios/peiosutils/transforming-text/sort.md), 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_

> Compare two sorted files line by line and report which lines they share.

`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`](/peios/using-peios/peiosutils/transforming-text/sort.md).

`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_

> Break a file into equal-sized pieces, each written to its own output file.

`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`](/peios/using-peios/peiosutils/viewing-and-joining-text/cat.md)` xaa xab xac … > original`.

Each piece is a newly created file, and a new file needs a [security descriptor](/peios/security-fundamentals/security-descriptors/overview.md); 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 `K`th 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_

> Break a file into pieces at lines chosen by line number or by a matching pattern.

`csplit` — "context split" — breaks a file into pieces at points *you* choose: a line number, or a line that matches a pattern. Where [`split`](/peios/using-peios/peiosutils/viewing-and-joining-text/split.md) 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](/peios/security-fundamentals/security-descriptors/overview.md); 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_

> Dump the raw bytes of a file in octal, hexadecimal, decimal, floating-point, or character form — a low-level view of exactly what a file contains.

`od` — short for "octal dump" — prints the raw bytes of a file. Where [`cat`](/peios/using-peios/peiosutils/viewing-and-joining-text/cat.md) 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 [`-A`](#the-offset-column) to 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](/peios/security-fundamentals/security-descriptors/overview.md)). `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_

> The commands that reshape text — reordering lines, removing duplicates, substituting characters, reflowing paragraphs, and counting.

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](/peios/using-peios/peiosutils/viewing-and-joining-text/overview.md), covers printing and combining files; this topic is about changing the text itself.

## The commands

**Ordering lines**

| Command | Purpose |
|---|---|
| [`sort`](/peios/using-peios/peiosutils/transforming-text/sort.md) | Sort lines — alphabetically, numerically, and many other ways. |
| [`shuf`](/peios/using-peios/peiosutils/transforming-text/shuf.md) | The opposite of sorting: put lines into a random order. |
| [`tsort`](/peios/using-peios/peiosutils/transforming-text/tsort.md) | Topological sort — order items so that each comes after the things it depends on. |

**Filtering lines**

| Command | Purpose |
|---|---|
| [`uniq`](/peios/using-peios/peiosutils/transforming-text/uniq.md) | Collapse or report adjacent repeated lines. |

**Substituting characters**

| Command | Purpose |
|---|---|
| [`tr`](/peios/using-peios/peiosutils/transforming-text/tr.md) | Translate, squeeze, or delete individual characters. |

**Whitespace**

| Command | Purpose |
|---|---|
| [`expand`](/peios/using-peios/peiosutils/transforming-text/expand.md) | Convert tabs into spaces. |
| [`unexpand`](/peios/using-peios/peiosutils/transforming-text/unexpand.md) | Convert runs of spaces back into tabs. |

**Reflowing and paginating**

| Command | Purpose |
|---|---|
| [`fmt`](/peios/using-peios/peiosutils/transforming-text/fmt.md) | Reflow paragraphs to a target width. |
| [`fold`](/peios/using-peios/peiosutils/transforming-text/fold.md) | Hard-wrap long lines at a fixed width. |
| [`pr`](/peios/using-peios/peiosutils/transforming-text/pr.md) | Paginate and columnate text for printing. |

**Indexing and counting**

| Command | Purpose |
|---|---|
| [`ptx`](/peios/using-peios/peiosutils/transforming-text/ptx.md) | Produce a permuted index of the words in a file. |
| [`wc`](/peios/using-peios/peiosutils/transforming-text/wc.md) | Count the lines, words, and bytes in a file. |

## Two commands that need sorted input

A theme worth knowing before you start: [`uniq`](/peios/using-peios/peiosutils/transforming-text/uniq.md) 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`](/peios/using-peios/peiosutils/transforming-text/sort.md) is the workhorse of the topic and the one with the most depth — start there.

---

# sort

_Peios / Using Peios / Peiosutils / Transforming text_

> Sort the lines of files — alphabetically, numerically, by field, and many other ways.

`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_

> Collapse or report adjacent repeated lines in a file.

`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`](/peios/using-peios/peiosutils/transforming-text/sort.md), 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_

> Translate, squeeze, or delete individual characters in a stream of 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 `set1` is replaced by the character at the same position in `set2`. `tr 'abc' 'xyz'` turns every `a` into `x`, `b` into `y`, `c` into `z`.
- **Delete** (`-d`) — give one set. Every character in `set1` is 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_

> Convert tab characters in a file into spaces.

`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`](/peios/using-peios/peiosutils/transforming-text/unexpand.md) 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_

> Convert runs of spaces in a file back into tab characters.

`unexpand` is the reverse of [`expand`](/peios/using-peios/peiosutils/transforming-text/expand.md): 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_

> Reflow paragraphs of text to a target line width.

`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`](/peios/using-peios/peiosutils/transforming-text/fold.md), 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_

> Hard-wrap long lines at a fixed width.

`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`](/peios/using-peios/peiosutils/transforming-text/fmt.md): `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_

> Paginate and columnate text for printing.

`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_

> Produce a permuted index of the words in a file.

`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_

> Topologically sort items so that each comes after the things it depends on.

`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`](/peios/using-peios/peiosutils/transforming-text/sort.md), 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_

> Count the lines, words, and bytes in files.

`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_

> Output the input lines in a random order.

`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`](/peios/using-peios/peiosutils/transforming-text/sort.md) — 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_

> The commands that produce text, work with the environment, convert numbers, and evaluate expressions and conditions.

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`](/peios/using-peios/peiosutils/output-and-evaluation/echo.md) | Print its arguments as a line of text. |
| [`printf`](/peios/using-peios/peiosutils/output-and-evaluation/printf.md) | Print text from a format string, with precise control over layout. |
| [`yes`](/peios/using-peios/peiosutils/output-and-evaluation/yes.md) | Print a line over and over, without stopping. |
| [`seq`](/peios/using-peios/peiosutils/output-and-evaluation/seq.md) | Print a sequence of numbers. |
| [`tee`](/peios/using-peios/peiosutils/output-and-evaluation/tee.md) | Copy standard input to standard output and to each named file. |

**The environment**

| Command | Purpose |
|---|---|
| [`env`](/peios/using-peios/peiosutils/output-and-evaluation/env.md) | Run a command with a modified environment — or print the current one. |
| [`printenv`](/peios/using-peios/peiosutils/output-and-evaluation/printenv.md) | Print environment variables. |

**Numbers**

| Command | Purpose |
|---|---|
| [`numfmt`](/peios/using-peios/peiosutils/output-and-evaluation/numfmt.md) | Convert numbers to and from human-readable forms (`1.5G`, `1000000`). |
| [`factor`](/peios/using-peios/peiosutils/output-and-evaluation/factor.md) | Print the prime factors of a number. |

**Evaluating**

| Command | Purpose |
|---|---|
| [`expr`](/peios/using-peios/peiosutils/output-and-evaluation/expr.md) | Evaluate an arithmetic, string, or comparison expression. |
| [`test`](/peios/using-peios/peiosutils/output-and-evaluation/test.md) | Evaluate a condition — a file check or a comparison — as a true/false result. |

**Exit status**

| Command | Purpose |
|---|---|
| [`true` and `false`](/peios/using-peios/peiosutils/output-and-evaluation/true-and-false.md) | 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`](/peios/using-peios/peiosutils/output-and-evaluation/test.md) is the clearest case — it prints nothing and exists *only* to set an exit status from a condition. [`true` and `false`](/peios/using-peios/peiosutils/output-and-evaluation/true-and-false.md) 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`](/peios/using-peios/peiosutils/output-and-evaluation/echo.md); for anything that needs columns, padding, or precise formatting, [`printf`](/peios/using-peios/peiosutils/output-and-evaluation/printf.md).

---

# echo

_Peios / Using Peios / Peiosutils / Output and evaluation_

> Print arguments as a line of text to standard output.

`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`](/peios/using-peios/peiosutils/output-and-evaluation/printf.md) 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_

> Print formatted text from a format string and a list of arguments.

`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`](/peios/using-peios/peiosutils/output-and-evaluation/echo.md) 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_

> Print a line repeatedly until stopped.

`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_

> Print a sequence of numbers from a start to an end.

`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 to `last`.
- `seq first last` — count from `first` up to `last`.
- `seq first increment last` — count from `first` to `last`, stepping by `increment`.

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`](/peios/using-peios/peiosutils/output-and-evaluation/printf.md) 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_

> Run a command with a modified environment, or print the current environment.

`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_

> Print the values of environment variables.

`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`](/peios/using-peios/peiosutils/output-and-evaluation/env.md) 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_

> Convert numbers between plain digits and human-readable forms.

`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_

> Evaluate an arithmetic, string, or comparison expression and print the result.

`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_

> Print the prime factors of numbers.

`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_

> Evaluate a condition — a file check or a comparison — and report the result as an exit status.

`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](/peios/security-fundamentals/security-descriptors/overview.md), 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](/peios/security-fundamentals/access-decisions/overview.md).

### 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_

> Two commands that do nothing — one always succeeds, the other always fails.

`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 true` is the standard way to write a loop that runs until something inside it breaks out.
- **A deliberate placeholder.** Setting a configurable command to `true` makes that step do nothing and "succeed"; `false` makes 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, `true` and `false` provide it.

They are the simplest commands there are — and the [overview](/peios/using-peios/peiosutils/output-and-evaluation/overview.md)'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_

> Copy standard input to standard output and to each named file at once.

`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](/peios/security-fundamentals/security-descriptors/overview.md), 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](/peios/using-peios/peiosutils/files-and-directories/overview.md) 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_

> The commands that compute checksums and hashes to verify data integrity, and that encode binary data into text and back.

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](/peios/using-peios/peiosutils/hashing-and-encoding/checksum-commands.md) | `md5sum`, `sha1sum`, `sha256sum`, and the rest — one command per hash algorithm. |
| [`cksum`](/peios/using-peios/peiosutils/hashing-and-encoding/cksum.md) | The general checksum tool — any of the algorithms, selected by an option. |
| [`sum`](/peios/using-peios/peiosutils/hashing-and-encoding/sum.md) | A small, legacy block-checksum. |

**Encoding**

| Command | Purpose |
|---|---|
| [`base32` and `base64`](/peios/using-peios/peiosutils/hashing-and-encoding/base32-and-base64.md) | Encode binary data as text using the base32 or base64 alphabet, and decode it back. |
| [`basenc`](/peios/using-peios/peiosutils/hashing-and-encoding/basenc.md) | 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](/peios/using-peios/peiosutils/hashing-and-encoding/checksum-commands.md). To turn binary data into something safe to paste into text, read [`base32` and `base64`](/peios/using-peios/peiosutils/hashing-and-encoding/base32-and-base64.md).

---

# Checksum commands

_Peios / Using Peios / Peiosutils / Hashing and encoding_

> md5sum, sha1sum, sha256sum and the rest — the family of commands that each compute and verify one fixed hash algorithm.

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_

> The general checksum tool — compute or verify a digest with any of the supported algorithms.

`cksum` is the **general** checksum command. Where each of the [checksum commands](/peios/using-peios/peiosutils/hashing-and-encoding/checksum-commands.md) 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`](/peios/using-peios/peiosutils/hashing-and-encoding/sum.md). |

`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_

> Compute a small, legacy block checksum of a file.

`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](/peios/using-peios/peiosutils/hashing-and-encoding/checksum-commands.md) such as `sha256sum`, or [`cksum`](/peios/using-peios/peiosutils/hashing-and-encoding/cksum.md) — 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_

> Encode binary data as text using the base32 or base64 alphabet, and decode it back.

`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](/peios/using-peios/peiosutils/hashing-and-encoding/overview.md).

## 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_

> The general encoder — encode and decode data in base64, base32, base16, and several other encodings.

`basenc` is the **general** encoding command. Where [`base32` and `base64`](/peios/using-peios/peiosutils/hashing-and-encoding/base32-and-base64.md) 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_

> The commands that report on the system, run other commands in a managed way, signal processes, and work with time, terminals, and storage.

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`](/peios/using-peios/peiosutils/system-and-processes/date.md) | Print — or set — the system date and time. |
| [`sleep`](/peios/using-peios/peiosutils/system-and-processes/sleep.md) | Pause for a given length of time. |
| [`uptime`](/peios/using-peios/peiosutils/system-and-processes/uptime.md) | Show how long the system has been running. |

**Running a command under a constraint**

| Command | Purpose |
|---|---|
| [`timeout`](/peios/using-peios/peiosutils/system-and-processes/timeout.md) | Run a command, and kill it if it runs too long. |
| [`nohup`](/peios/using-peios/peiosutils/system-and-processes/nohup.md) | Run a command so it survives the terminal closing. |
| [`nice`](/peios/using-peios/peiosutils/system-and-processes/nice.md) | Run a command at an adjusted scheduling priority. |
| [`stdbuf`](/peios/using-peios/peiosutils/system-and-processes/stdbuf.md) | Run a command with altered stream buffering. |
| [`chroot`](/peios/using-peios/peiosutils/system-and-processes/chroot.md) | Run a command with a different directory as its root. |

**Signalling processes**

| Command | Purpose |
|---|---|
| [`kill`](/peios/using-peios/peiosutils/system-and-processes/kill.md) | Send a signal to a process. |

**System identity and capacity**

| Command | Purpose |
|---|---|
| [`uname`](/peios/using-peios/peiosutils/system-and-processes/uname.md) | Print system information — the OS, the kernel, the machine. |
| [`arch`](/peios/using-peios/peiosutils/system-and-processes/arch.md) | Print the machine's hardware architecture. |
| [`hostname`](/peios/using-peios/peiosutils/system-and-processes/hostname.md) | Display — or set — the system's host name. |
| [`hostid`](/peios/using-peios/peiosutils/system-and-processes/hostid.md) | Print the host's numeric identifier. |
| [`nproc`](/peios/using-peios/peiosutils/system-and-processes/nproc.md) | Print how many processor cores are available. |

**Who you are**

| Command | Purpose |
|---|---|
| [`whoami`](/peios/using-peios/peiosutils/system-and-processes/whoami.md) | Print the name of the user a process is running as. |
| [`id`](/peios/using-peios/peiosutils/system-and-processes/id.md) | Print the user and group identity of a process, or of a named user. |
| [`groups`](/peios/using-peios/peiosutils/system-and-processes/groups.md) | Print the groups a user belongs to. |
| [`logname`](/peios/using-peios/peiosutils/system-and-processes/logname.md) | 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`](/peios/using-peios/peiosutils/system-and-processes/id.md) explains what they can and cannot say, and [`token`](/peios/security-fundamentals/tokens/token-command.md) shows the identity underneath them.

**Storage and terminal**

| Command | Purpose |
|---|---|
| [`sync`](/peios/using-peios/peiosutils/system-and-processes/sync.md) | Flush cached writes out to persistent storage. |
| [`tty`](/peios/using-peios/peiosutils/system-and-processes/tty.md) | Print the name of the terminal on standard input. |
| [`stty`](/peios/using-peios/peiosutils/system-and-processes/stty.md) | 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](/peios/security-fundamentals/privileges/overview.md) is the full picture.

## Where to start

[`uname`](/peios/using-peios/peiosutils/system-and-processes/uname.md) answers "what am I running on?". [`kill`](/peios/using-peios/peiosutils/system-and-processes/kill.md) 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_

> Print or set the system date and time, with a flexible output format.

`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](/peios/security-fundamentals/privileges/overview.md).

## 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_

> Pause for a given length of time.

`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_

> Run a command and kill it if it is still running after a time limit.

`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_

> Run a command so it keeps running after the terminal closes — and how the nohup.out it creates is secured.

`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.out` in the current directory. If that file cannot be opened there, `nohup` falls back to `nohup.out` in 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](/peios/security-fundamentals/security-descriptors/overview.md). `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_

> Run a command at an adjusted scheduling priority.

`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_

> Send a signal to a process.

`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_

> Print system information — the operating system, the kernel, and the machine.

`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`](/peios/using-peios/peiosutils/system-and-processes/arch.md) 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_

> Print the machine's hardware architecture.

`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`](/peios/using-peios/peiosutils/system-and-processes/uname.md) 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_

> Display or set the system's host name.

`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](/peios/security-fundamentals/privileges/overview.md).

## 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_

> Print the host's numeric identifier.

`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`](/peios/using-peios/peiosutils/system-and-processes/hostname.md) 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_

> Print how many processor cores are available.

`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_

> Show how long the system has been running.

`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](/peios/security-fundamentals/access-decisions/overview.md) 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_

> Flush cached writes out to persistent storage.

`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_

> Print the name of the terminal connected to standard input.

`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_

> Display or change terminal settings.

`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_

> Run a command with altered buffering on its standard streams.

`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_

> Run a command with a different directory as its root.

`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](/peios/security-fundamentals/privileges/overview.md).

## 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_

> Print the name of the user a process is running as.

`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](/peios/security-fundamentals/tokens/overview.md) 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](/peios/security-fundamentals/managing-local-principals/resolving-names.md) 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_

> Print the user and group identity of a process, or of a named user.

`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](/peios/security-fundamentals/identity/sids.md). 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](/peios/security-fundamentals/access-decisions/overview.md). 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](/peios/security-fundamentals/tokens/restricted-tokens.md): 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](/peios/security-fundamentals/tokens/overview.md), 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`](/peios/security-fundamentals/tokens/token-command.md).

## 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](/peios/security-fundamentals/impersonation/overview.md): 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](/peios/security-fundamentals/auditing/overview.md) 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 -G`** reads 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 any `Administrators`-style aliases.
- **`id -G jack`** looks the name up through the [principal store](/peios/security-fundamentals/managing-local-principals/resolving-names.md), 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_

> Print the groups a user belongs to.

`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](/peios/security-fundamentals/tokens/overview.md) 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](/peios/security-fundamentals/managing-local-principals/resolving-names.md), 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`, `Batch` and `Service` are **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 in `Interactive` at 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`](/peios/security-fundamentals/tokens/token-command.md) 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_

> Print the login name of the user a process is running as.

`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`](/peios/using-peios/peiosutils/system-and-processes/whoami.md) 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](/peios/security-fundamentals/impersonation/overview.md) 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_

> The token command inspects and manipulates tokens — reading a token's contents, adjusting it, duplicating and restricting it, and driving impersonation.

`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](/peios/security-fundamentals/impersonation/peer-tokens.md). |

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](/peios/security-fundamentals/tokens/restricted-tokens.md) — 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](/peios/security-fundamentals/tokens/elevation.md).

## 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](/peios/security-fundamentals/impersonation/overview.md) 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](/peios/security-fundamentals/inspecting/tokens.md) — 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_

> The logonse command lists logon sessions and their processes, creates and destroys them, and sets a process's mitigation flags.

`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](/peios/security-fundamentals/inspecting/sessions.md) — 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.

> [!NOTE]
> The tool builds the kernel's binary session spec from these fields for you. The underlying [wire format](/peios/using-peios/wire-formats-reference/token-and-session-specs.md) is unchanged; only the command-line surface is typed.

### `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](/peios/security-fundamentals/logon-sessions/lifecycle.md).

## 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](/peios/security-fundamentals/process-mitigations/overview.md).

## 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_

> Attach a filesystem to the Peios mount tree — new mounts, binds, moves, remounts and propagation changes — and apply a KACS mount policy at mount time.

`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](/peios/using-peios/mount-policies/overview.md)).

```
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](#list-mode)).
- **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 TARGET` pair. Supply both, or use `--source` / `--target`.
- **A lone target** is valid only for `-o remount` and 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](#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](#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](#list-mode)).

## 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=`:

```sh
mount -t stratafs none /bin -o 'strata=/lcl/bin+create:/usr/bin+ro'
```

See [StrataFS](/peios/using-peios/disks-and-filesystems/stratafs/overview.md) 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](#propagation-flags); 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](/peios/using-peios/mount-policies/overview.md) 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](/peios/using-peios/mount-policies/policy-classes.md) for what each class does and [SD storage by filesystem](/peios/using-peios/mount-policies/sd-storage-by-filesystem.md) 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](/peios/using-peios/mount-policies/managing-mounts.md)), 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`](/peios/using-peios/mount-policies/umount.md)).

## 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`](/peios/using-peios/mount-policies/lsblk.md).

## 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_

> Detach a filesystem from the Peios mount tree by mount point or by source, with lazy, forced, recursive and all-targets variants.

`umount` detaches a filesystem from the Peios mount tree. It is the counterpart of [`mount`](/peios/using-peios/mount-policies/mount.md) 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, `umount` refuses with an error naming the candidates — resolve it by naming a specific mount point, or use `-A` to 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` / `--recursive`** unmounts the target and everything mounted underneath it, including any over-mount stack at a single point, ordered deepest-first.
- **`-A` / `--all-targets`** unmounts *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 -R` together** 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`](/peios/using-peios/mount-policies/mount.md) with no arguments or [`lsblk`](/peios/using-peios/mount-policies/lsblk.md).

---

# lsblk

_Peios / Using Peios / Peiosutils / Disks and mounts_

> List block devices as a tree, flat list, JSON or key=value pairs — with filesystem identity, topology, and SD-derived owner and mode columns.

`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`](/peios/using-peios/mount-policies/mount.md): 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/block` unpatched, 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** — `OWNER` and `MODE`, read the same way [`ls -l`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) reads them.
- **The `/dev/disk/by-*` symlink farm** — `ID-LINK`. Populated by the device manager once it has run; there is deliberately no `/run/udev/data` parser, 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`](/peios/using-peios/peiosutils/listing-and-paths/ls.md) 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_

> Compile the /boot/initramfs/ source tree into a deterministic cpio initramfs image, resolving hook order and validating the layout. Has a watch mode.

`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/`](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md) — 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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md); for how hooks declare their order, see [Boot hooks](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md). `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:

1. **Validate the layout.** `<src-dir>` must be a directory, must carry an executable `init` (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.seq` or `system/prelude/hooks.seq.<n>` — which `mkirf` generates. A missing or non-executable `init`, or a stray sequence file, stops the build.
2. **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 in `LC_ALL=C` byte order, which places every directory before its descendants — the order the kernel's unpacker requires.
3. **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. `mkirf` parses each hook's `# /// hook` metadata 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 missing `hooks/` 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 `mkirf` writes every version it can express faithfully — currently `hooks.seq.1` (the flat resolved order) and `hooks.seq.2` (that order plus each hook's declarations). `mkirf` ships in `peiosutils` and 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, alongside `system/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](#validation-and-errors) below.
4. **Write atomically.** The archive is written to a sibling temp file and `rename`d into place, so a failed run never leaves a half-written image behind. On success `mkirf` prints 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`, symlinks `0777`, FIFOs and device nodes `0644`, regular files `0755` if executable and `0644` otherwise.
- **The gzip member** carries mtime 0 and no embedded filename (the `gzip -n` equivalent), 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; no `init`, or `init` is 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 `# /// hook` metadata block (unclosed, a non-comment line inside it, a duplicate or unknown key, a badly-formed array), a dependency **cycle**, a `requires` that **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). An `after` naming a capability nothing supplies is *not* an error — that is what distinguishes it from `requires`. (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, and `mkirf` prints 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, `--watch` with `<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](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md) — what `mkirf`'s output is for.
- [Boot hooks](/peios/security-fundamentals/boot-and-trust-establishment/boot-hooks.md) — the `# /// hook` metadata block and how order is resolved.
- [mkuki](/peios/security-fundamentals/boot-and-trust-establishment/mkuki.md) — 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_

> Build a UEFI unified kernel image — kernel, initramfs, and command line appended as PE sections onto an EFI stub. Has a watch mode.

`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`](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md) 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 `rename`s 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.

> [!NOTE]
> `mkuki` does **not** sign the image. Producing the unified binary and signing it for Secure Boot are separate steps; `mkuki` has no signing options. Because a UKI bundles the kernel, initramfs, and command line into one PE image, that image is what a later Secure Boot milestone signs and the firmware verifies as a whole — but the signing itself is out of `mkuki`'s scope.

## 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](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md), 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](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md) — builds the `.initrd` payload `mkuki` wraps.
- [The initramfs stage](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md) — what the bundled initramfs does once firmware boots the UKI.

---

# reg

_Peios / Using Peios / Peiosutils / Registry tools_

> The reg command reads and writes the live registry — values, keys, layers, links, security descriptors, watches, and atomic batches.

`reg` is the command-line interface to the **live** registry — the running [LCS](/peios/using-peios/registry-administration/lcs-and-sources.md) store, reached through the registry system calls. Where [`regman`](/peios/using-peios/registry-administration/regman.md) 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`](/peios/using-peios/registry-administration/regman.md) 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`](/peios/using-peios/registry-administration/regman.md) |

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](/peios/using-peios/registry-security/access-control.md) 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 `.reg` convention: `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](/peios/using-peios/registry-layers/layers.md) 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](#value-literals-and-types) 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](/peios/using-peios/registry-layers/deleting-keys-and-values.md) 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](/peios/using-peios/registry-layers/layers.md) 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](/peios/using-peios/registry-advanced/private-hives-and-layers.md).

---

## Security descriptors

### `reg sd <key>`

Show or set a key's [security descriptor](/peios/security-fundamentals/security-descriptors/overview.md) as SDDL, using the same codec as the [`sd`](/peios/security-fundamentals/security-descriptors/overview.md) 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](/peios/using-peios/registry-security/access-control.md).

---

## 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](/peios/using-peios/registry-advanced/registry-links.md).

---

## 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](/peios/using-peios/registry-concepts/watches.md) 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](/peios/using-peios/registry-advanced/transactions.md) — 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](/peios/using-peios/registry-administration/backup-and-restore.md) 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`](/peios/using-peios/registry-administration/regman.md) — the registry *manual*: what a key or value *means*, as opposed to what it is set to. Consult it before you `reg set`.
- [Keys, values, and types](/peios/using-peios/registry-concepts/keys-values-and-types.md) — the data model `reg` addresses.
- [Layers](/peios/using-peios/registry-layers/layers.md) — what "effective" resolves against, and what `--layer`, `mask`, and `hide` operate on.
- [Access control on keys](/peios/using-peios/registry-security/access-control.md) — the check every `reg` operation goes through.

---

# regman

_Peios / Using Peios / Peiosutils / Registry tools_

> regman documents what a registry key or value means — type, default, valid range, and when a change applies — from shipped docs, never the live registry.

[Configuration, not storage](/peios/using-peios/registry-concepts/configuration-and-meaning.md) 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](/peios/using-peios/registry-administration/lcs-and-sources.md) 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`](/peios/using-peios/registry-administration/reg.md)'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](/peios/using-peios/registry-concepts/configuration-and-meaning.md) 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`](/peios/using-peios/registry-administration/reg.md) — a live query against the store |
| What the subsystem is *actually running on* | the [event log](/peios/security-fundamentals/auditing/overview.md) |

`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](/peios/using-peios/registry-concepts/configuration-and-meaning.md): `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](/peios/using-peios/registry-concepts/configuration-and-meaning.md).

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](/peios/using-peios/registry-administration/lcs-and-sources.md).

---

# 1.1 What an Event Is

_Peios / Using Peios / Events / Introduction_

> What counts as an event on a Peios system, the two transports that carry them, and what this book deliberately leaves out.

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_

> The packed binary header in front of every KMES payload — the fields the kernel stamps, the layout, and what ordering you get.

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 payload is a msgpack map with string keys — how the recurring value types are represented, and the three event-type naming styles.

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_

> The four rules every consumer of this stream lives by — ignore unknown keys, do not read meaning into absence, expect loss, and do not trust the contents.

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 identifying the effective token behind an operation — its fields, the two parallel arrays, and what it deliberately omits.

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 identifying where an event came from, what to correlate on, and why there is no thread ID.

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_

> The registry's own identity submap, why LCS carries it instead of the subject record, and how the two compare field by field.

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 — what triggers it, why one access can produce several, and a worked example.

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_

> Per-operation auditing on an already-open handle — the three masks involved, and why the subject is re-read each time.

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_access`** is what the *operation* needs. A read needs
  `FILE_READ_DATA`; a write needs `FILE_WRITE_DATA`; an `mmap` with
  `PROT_EXEC` needs `FILE_EXECUTE`.
- **`matched_access`** is `requested_access` intersected with the
  handle's continuous audit mask. This is why the event exists — the
  bits that triggered it.
- **`granted_access`** is what the handle was opened with. An operation
  can only succeed if `requested_access` is 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_

> Recording a privilege that contributed bits to the granted mask — the five that can appear, and what success actually means here.

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**:

- `SeSecurityPrivilege`
- `SeTakeOwnershipPrivilege`
- `SeBackupPrivilege`
- `SeRestorePrivilege`
- `SeRelabelPrivilege`

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_

> Two unrelated central-access-policy conditions sharing one event type, told apart by the kind field — and the limits of the mismatch report.

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_

> The event fired when a logon session loses its last token reference — why it has no subject or process, and why nothing matches it at creation.

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_

> What FACS emits on finding a structurally invalid descriptor on a file, why it is rate-limited, and the related events that do not exist.

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_

> The event fired on every StrataFS copy-up, successful or not — six keys, no caller identity, and where ENOTDIR actually surfaces.

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_

> Refusals that come from how a mount is arranged rather than from an access check — what counts, two irregularities, and what is not audited at all.

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_

> The seven audit events LCS emits, which carry the caller summary, and which are audited unconditionally.

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_

> The event fired when a key open matches a SACL audit ACE, the SACL that produces nothing, and what happens when emission fails.

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_

> The two events LCS emits for malformed source data and invalid self-configuration — and why a first boot produces nineteen of them.

## 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 — binary records read from a key file descriptor, their layout, and what a subtree watch adds.

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_

> The three events peinit emits across a job's lifecycle, their payloads, and the ordering guarantee between them.

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_

> The seven operation lifecycle events, the five fields they share, what duration_ns measures from, and the one field that goes by three names.

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_reason` on `operation.failed`
- `reason` on `operation.cancelled` and `operation.aborted`
- `error` in 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_

> The eleven event types peipkg emits, their payload fields, and the privilege emission depends on.

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 specific gaps in peipkg's audit trail — each a place where something happened and nothing was written — and how to read the trail knowing them.

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 recover` produces
  `peipkg.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 `optional` policy** — the
  two decisions most worth recording are the two that are not.
- **`peipkg-compose` emits 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_

> The records eventd generates about itself, written straight to a shard and never through KMES — which shard, what the timestamp means, and what has no event at all.

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 and the known holes.

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-change` is 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 `optional` policy, or
  `peipkg-compose` (§7.2).
- A registry key open requesting `MAXIMUM_ALLOWED` alone emits no
  `LCS_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-diagnostic` with `kind = staging-mismatch` does not
  identify which rule differed, and its two masks can be equal while
  `object_results_differ` is true (§3.4).
- There is no `logon-session-created`, no `token-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_

> Where every numeric constant in Peios is catalogued — access rights and GenericMappings here, ACE types and well-known SIDs in PCDS, privileges in the kernel manual.

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](/peios/using-peios/constants-and-catalogs/access-mask-bits.md) | Per-object-type rights for files, processes, tokens, registry keys and services; the GenericMapping tables; the `*_ALL_ACCESS` and `STANDARD_RIGHTS_*` aggregates. |
| [Other constants](/peios/using-peios/constants-and-catalogs/other-constants.md) | 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](/peios/using-peios/constants-and-catalogs/ace-types-and-flags.md) |
| Well-known SIDs | PCDS §4.4 — see [Well-known SIDs](/peios/using-peios/constants-and-catalogs/well-known-sids.md) |
| Every privilege, with its LUID bit | Peios Kernel TRM §3.4.2 — see [Privilege catalog](/peios/using-peios/constants-and-catalogs/privilege-catalog.md) |

The three pages above are signposts. Following one gets you to the table.

> [!NOTE]
> A constant with two spellings is listed under the name you would meet it by. Where a header and a specification disagree — and they do, for impersonation levels, create dispositions and the group attribute flags — the page says so and names both. The full mapping is the Peios Kernel TRM §3.A.

## Related catalogues

- Every **event type** the system emits: the [Peios Events Index](/peios/using-peios/events/all-event-types.md).
- Every **audit event's payload schema**: the same book, chapters 3 to 8.

---

# Well-known SIDs

_Peios / Using Peios / Constants and Catalogs_

> Where the well-known SID catalogue lives — PCDS §4.4 — and the PIP trust label ladder.

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](/peios/security-fundamentals/identity/well-known-principals.md).

## 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](/peios/using-peios/constants-and-catalogs/other-constants.md), and the full SID list is in PCDS §4.4. The mechanism is [Process integrity protection](/peios/security-fundamentals/process-integrity-protection/overview.md).

---

# Privilege catalog

_Peios / Using Peios / Constants and Catalogs_

> Where the per-privilege catalogue lives — the Peios Kernel TRM — and the two privileges that are enforced but unnamed.

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](/peios/security-fundamentals/privileges/overview.md), and the four-category model is [Categories](/peios/security-fundamentals/privileges/categories.md).

## 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:

- `SeSecurityPrivilege`
- `SeTakeOwnershipPrivilege`
- `SeBackupPrivilege`
- `SeRestorePrivilege`
- `SeRelabelPrivilege`

Any other bit fails the audit encoder closed rather than emitting an unnamed privilege. See the [Events Index §3.3](/peios/using-peios/events/kernel-access-events/privilege-use.md).

## 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_

> Where the ACE type and flag catalogues live — PCDS §5.4 — including the AceType constant table and the MIC policy bits.

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](/peios/security-fundamentals/access-decisions/mandatory-integrity-control.md).

---

# Access mask bits

_Peios / Using Peios / Constants and Catalogs_

> The per-object-type access rights — file, process, token, registry key, service — plus the standard, special and generic rights, the GenericMapping tables, and the aggregate *_ALL_ACCESS constants.

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 — impersonation levels, integrity levels, logon types, PIP tiers, token audit policy, create dispositions, SECURITY_INFORMATION flags, mitigation flags, and the kernel's size limits.

Enumerated values that are not access rights. Access rights are catalogued separately in [Access mask bits](/peios/using-peios/constants-and-catalogs/access-mask-bits.md).

> [!NOTE]
> Names here are the ones `uapi/pkm/` declares, because that is where a reader meets them. Several were published under different spellings in older design documents; where that is so, this page says which. The Peios Kernel TRM §3.A carries the full mapping.

## Impersonation levels

A token's impersonation level. The conceptual model is [Impersonation levels](/peios/security-fundamentals/impersonation/impersonation-levels.md).

| 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](/peios/security-fundamentals/access-decisions/mandatory-integrity-control.md).

| 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](/peios/security-fundamentals/logon-sessions/logon-types.md).

| 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](/peios/using-peios/events/kernel-access-events/access-audit.md).

| 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](/peios/security-fundamentals/process-mitigations/catalog.md).

## 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_

> Where every byte-level layout in Peios is specified — security descriptors, SIDs, conditional-ACE bytecode, claim entries, token and session specs, CAAP, and the KMES event envelope.

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](/peios/using-peios/wire-formats-reference/security-descriptors.md).

## 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](/peios/using-peios/wire-formats-reference/token-and-session-specs.md) |
| CAAP wire format | See [CAAP format](/peios/using-peios/wire-formats-reference/caap-format.md) |
| 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](/peios/using-peios/events/all-event-types.md) §1.2 |
| Per-event payload schemas | [Events Index](/peios/using-peios/events/all-event-types.md), 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_

> Where the byte-level security descriptor layouts are specified — PCDS chapters 4 and 5.

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](/peios/using-peios/constants-and-catalogs/access-mask-bits.md).

---

# CAAP format

_Peios / Using Peios / Wire Formats Reference_

> Where the central access policy wire format is specified.

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](/peios/security-fundamentals/central-access-policies/policies-and-rules.md).

## 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](/peios/using-peios/constants-and-catalogs/other-constants.md).

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_

> Where the binary token and session specifications are laid out — the Peios Kernel TRM's KACS ABI appendix.

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](/peios/security-fundamentals/tokens/token-types.md).

## 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](/peios/using-peios/constants-and-catalogs/other-constants.md).

The enumerated values a spec carries — impersonation level, elevation type, logon type, mandatory policy, audit policy — are catalogued in [Other constants](/peios/using-peios/constants-and-catalogs/other-constants.md) 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_

> Where the KACS and LCS kernel ABIs are documented — syscall numbers, ioctls, structure layouts, and constants, generated from uapi.

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](/peios/using-peios/wire-formats-reference/overview.md).
- Numeric constants by kind: [Constants and catalogs](/peios/using-peios/constants-and-catalogs/overview.md).
- The tracepoints the kernel exposes: [Kernel tracepoints](/peios/developing-for-peios/debugging-the-kernel/kernel-tracepoints.md).

---

# Linux compatibility

_Peios / Advanced Peios / Linux compatibility_

> Peios runs Linux software with little to no modification. The Linux identity APIs return values projected from KACS; the token stays authoritative.

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

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

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

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

## The projection model in one sentence

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

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

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

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

## What you see vs what counts

A Linux process running on Peios sees:

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

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

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

## What works, what changes

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

| Linux 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](/peios/advanced-peios/linux-compatibility/dac-neutralization-and-capabilities.md).

## What about the `root` user

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

The mapping is:

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

The `uid0` mechanism is covered in [setuid and uid0](/peios/advanced-peios/linux-compatibility/setuid-and-uid0.md). 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](/peios/advanced-peios/linux-compatibility/credential-projection.md).

If you want the capability story — DAC neutralisation, the 41 Linux capabilities classified, why `security_capable()` is authoritative — read [DAC neutralisation and capabilities](/peios/advanced-peios/linux-compatibility/dac-neutralization-and-capabilities.md).

If you want setuid semantics and the `uid0` utility, read [setuid and uid0](/peios/advanced-peios/linux-compatibility/setuid-and-uid0.md).

If you want peer credentials — `SO_PEERCRED`, `SCM_CREDENTIALS`, the rules for migrating Linux services to KACS-aware peer-identity APIs — read [Peer credentials](/peios/advanced-peios/linux-compatibility/peer-credentials.md).

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](/peios/advanced-peios/linux-compatibility/linux-relics.md).

For the merged filesystem used to combine packaged and local directory trees —
now covered with the rest of the storage tooling — read
[StrataFS](/peios/using-peios/disks-and-filesystems/stratafs/overview.md).

---

# Credential projection

_Peios / Advanced Peios / Linux compatibility_

> The token's identity is projected into Linux UID/GID fields — computed from the SID-to-UID mapping, cached on the token, and never written back.

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 (without `SeAssignPrimaryTokenPrivilege`) or trigger a full identity swap through authd (with the privilege). See [setuid and uid0](/peios/advanced-peios/linux-compatibility/setuid-and-uid0.md).
- The setuid-on-exec bit (`S_ISUID` in a file's mode) similarly does not modify the token without the privilege.
- `prctl(PR_SET_SECUREBITS)` and related operations do not affect the token's projection.

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

## File ownership in stat()

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

The flow:

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

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

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

## SO_PEERCRED and SCM_CREDENTIALS

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

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

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

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

For security purposes, the right tool is `kacs_open_peer_token` — it returns a token fd carrying the full identity. See [Peer credentials](/peios/advanced-peios/linux-compatibility/peer-credentials.md).

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

## What the projection is *not*

A few clarifications:

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

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

## Where to go next

For how Linux's own DAC and capability checks are made to defer to KACS, read [DAC neutralisation and capabilities](/peios/advanced-peios/linux-compatibility/dac-neutralization-and-capabilities.md).

For what the setuid family does — and does not do — to the projection, read [setuid and uid0](/peios/advanced-peios/linux-compatibility/setuid-and-uid0.md).

For the security-grade replacement for projected peer credentials, read [Peer credentials](/peios/advanced-peios/linux-compatibility/peer-credentials.md).

---

# DAC neutralisation and capabilities

_Peios / Advanced Peios / Linux compatibility_

> Every process carries a mandatory capability substrate that defers Linux DAC to KACS, and the 41 capabilities are classified ALLOW, PRIVILEGE, or DENY.

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

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

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

## The problem: DAC runs first

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

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

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

The same applies to capabilities. Operations gated by Linux capabilities (`CAP_SYS_ADMIN`, `CAP_NET_BIND_SERVICE`, etc.) check the capability at the relevant point. If the process lacks the capability, the operation fails. KACS's privilege model (`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_OVERRIDE` etc. silently leave them set. The process cannot opt out of DAC neutralisation.
- **It cannot grant DENY bits.** Attempts to add `CAP_SETFCAP` etc. silently fail (the bit stays clear).
- **For PRIVILEGE bits**, `capset()` cannot grant capabilities the corresponding KACS privilege does not provide. A process can drop the bit (which would normally drop the capability), but this does not affect the underlying KACS privilege — the privilege is the source of truth.

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

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

## prctl(PR_SET_KEEPCAPS) and the secure bits

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

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

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

## Capabilities at exec — file capabilities are dead

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

Under Peios, **file capabilities are dead**:

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

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

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

## What this looks like in practice

For an application running on Peios:

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

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

## Where to go next

For the projection that feeds `getuid` and friends, read [Credential projection](/peios/advanced-peios/linux-compatibility/credential-projection.md).

For the setuid family's reinterpretation, read [setuid and uid0](/peios/advanced-peios/linux-compatibility/setuid-and-uid0.md).

For the KACS privileges the PRIVILEGE-class capabilities map to, read [Privileges](/peios/security-fundamentals/privileges/overview.md).

---

# setuid and uid0

_Peios / Advanced Peios / Linux compatibility_

> Without SeAssignPrimaryTokenPrivilege, setuid is a no-op; with it, a full identity swap via authd. The uid0 utility gives legacy programs a cosmetic UID 0.

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

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

## The setuid syscalls

The Linux setuid family has several variants:

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

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

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

| Caller has `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:

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

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

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

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

### The privilege is rare

`SeAssignPrimaryTokenPrivilege` is held by:

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

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

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

## The setuid bit on exec

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

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

| Calling token has `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:

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

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

Crucially, **`uid0` does not change the KACS token**. The `current_fsuid()` patch (from [Credential projection](/peios/advanced-peios/linux-compatibility/credential-projection.md)) 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. The `setuid()` call is the final step that installs the result on the calling process.

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

## Migrating away from setuid

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

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

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

## Where to go next

For the projection behind the cosmetic UID values, read [Credential projection](/peios/advanced-peios/linux-compatibility/credential-projection.md).

For why setuid's Linux-level checks defer to KACS in the first place, read [DAC neutralisation and capabilities](/peios/advanced-peios/linux-compatibility/dac-neutralization-and-capabilities.md).

For acting on another identity without changing your own, read [Peer credentials](/peios/advanced-peios/linux-compatibility/peer-credentials.md).

---

# Peer credentials

_Peios / Advanced Peios / Linux compatibility_

> SO_PEERCRED and SCM_CREDENTIALS return projected UIDs — fine for logging, insufficient for security. kacs_open_peer_token is the real tool.

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_PEERCRED` for their own non-security checks continue to work.

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

## SCM_CREDENTIALS

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

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

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

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

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

## Why these are insufficient for security

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

In practice, this leads to two classes of issue:

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

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

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

## The replacement: 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_PEERCRED` would have returned).

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

The service can:

- **Inspect** the token (via `KACS_IOC_QUERY`) to make authorisation decisions on real identity, not projected UID.
- **Impersonate** the peer (via `KACS_IOC_IMPERSONATE` or `kacs_impersonate_peer`) to perform operations on their behalf — with the just-in-time pattern from [Peer tokens and capture](/peios/security-fundamentals/impersonation/peer-tokens.md).

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_PEERCRED` still shows the original peer's UID. The receiver can be confused about who they're actually serving.
- **Identity not present in projection.** A service that wants to make decisions based on the peer's integrity level cannot — there's no projection for it. A `SO_PEERCRED`-using service sees nothing about integrity; the call effectively ignores that axis of access control.

For each of these, `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](/peios/advanced-peios/linux-compatibility/credential-projection.md).

For capturing and impersonating a peer's full token, read [Peer tokens and capture](/peios/security-fundamentals/impersonation/peer-tokens.md).

For what a token carries that a `struct ucred` cannot, read [Tokens](/peios/security-fundamentals/tokens/overview.md).

---

# Linux relics

_Peios / Advanced Peios / Linux compatibility_

> Linux features that survive for compatibility but are superseded — each with the privilege it needs and the native replacement to use instead.

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](/peios/advanced-peios/linux-compatibility/overview.md).

For the native event pipeline that replaces process accounting, read [Events and transport](/peios/security-fundamentals/auditing/events-and-transport.md).

For following that event stream from the command line, read [The event stream](/peios/security-fundamentals/inspecting/the-event-stream.md).

---

# Identity for POSIX programs

_Peios / Advanced Peios / Linux compatibility_

> How getpwnam and friends reach Peios principals — no nsswitch.conf entry, no /etc/passwd, no PAM, and nothing resolves before authd is running.

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](/peios/security-fundamentals/managing-local-principals/overview.md) — 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](/peios/advanced-peios/pgss/logon/scope-and-roles.md), where a client collects what it is asked for and an authority decides — and the module-stacking model PAM is built on is the same one the paragraph above rejects.

## Before the authority, nothing resolves

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

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

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

## One call, one round trip

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

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

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

## What the return values mean

| 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](/peios/security-fundamentals/managing-local-principals/resolving-names.md) for the three kinds of membership and which of them can be listed.

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

## `getent` and the whole list

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

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

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

---

# 1.1 Scope

_Peios / Advanced Peios / Conventions / Introduction_

> What this book governs — the two classes of technical document Peios writes — and where it sits in the PCSA anthology.

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_

> Specification or manual, decided by one test — how many independently written parties have to agree. Most subsystems split across both.

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_

> The normative keywords used here, why the specification and manual halves carry different weight, and the order to read the chapters in.

## 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_

> Uppercase MUST, SHOULD and MAY carry their RFC 2119 meanings; lowercase does not. Requirements are stated against a role, and SHOULD is a real choice.

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.

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

---

# 2.2 Informative Text

_Peios / Advanced Peios / Conventions / Normative Language_

> Everything in a specification is normative unless marked otherwise — how to mark informative text, what it may not do, and what it is for.

## 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:

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

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

## 2.2.3 What informative text may not do

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

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

## 2.2.4 What informative text is for

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

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

---

# 2.3 Pseudocode

_Peios / Advanced Peios / Conventions / Normative Language_

> Pseudocode in a specification is normative, and the corpus-wide conventions it should be written in.

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).

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

---

# 3.1 Required Articles

_Peios / Advanced Peios / Conventions / Specification Structure_

> The fixed opening and closing shape of a specification book and of each of its chapters, so a reader finds the same things in the same places.

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:

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

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

## 3.1.4 Scope names its exclusions

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

---

# 3.2 Roles

_Peios / Advanced Peios / Conventions / Specification Structure_

> A multi-party specification names its roles and states every requirement against a role rather than a program — and conformance follows the roles.

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 every book in this anthology inherits — byte order, sizes, layout tables, notation, strings, hashes and timestamps.

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

## 3.3.1 Byte order

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

## 3.3.2 Sizes

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

## 3.3.3 Layout tables

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

## 3.3.4 Notation

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

## 3.3.5 Strings

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

## 3.3.6 Hashes and signatures

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

## 3.3.7 Timestamps

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

## 3.3.8 What a book's conventions article carries

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

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

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

---

# 3.4 Extension and Conformance

_Peios / Advanced Peios / Conventions / Specification Structure_

> How a format or protocol chapter states the way it may grow, and how a book states what conformance to it requires.

## 3.4.1 Extension

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

An extension article SHOULD distinguish:

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

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

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

## 3.4.2 Conformance

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

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

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

---

# 4.1 What a TRM Is

_Peios / Advanced Peios / Conventions / Technical Reference Manuals_

> An exhaustive descriptive reference for one component — one book each, authority drawn from the software, and what belongs in one.

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_

> A manual describes and never requires — no normative keywords, the indicative mood, and the lowercase constructions that are the real hazard.

## 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.

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

## 4.2.4 Register

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

---

# 4.3 Structure

_Peios / Advanced Peios / Conventions / Technical Reference Manuals_

> How a manual is shaped — the introduction, body chapters, a failure-modes chapter and appendices — as guidance rather than a template.

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 manual describes what the component does now, without narrating its divergences from a specification and without writing around them.

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

## 4.4.1 Divergences are not narrated

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

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

> [!NOTE]
> The reason is that a manual written as a divergence log rots into a
> second changelog nobody maintains, and undermines itself while doing
> it: a reader who is told the document is describing something wrong
> stops trusting the parts that are describing something right.
>
> It is also the wrong audience. The person who needs to know about a
> divergence is whoever will fix it, and they are looking at the
> tracker, not at a reference manual.

## 4.4.2 Which does not mean writing around it

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

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

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

## 4.4.3 Interim states

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

---

# 4.5 Proposals

_Peios / Advanced Peios / Conventions / Technical Reference Manuals_

> A TRM for software that does not exist yet — why the class exists, how it says so, and how it graduates in place once the software does.

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_

> A manual cites the contract it implements rather than paraphrasing it, and adds what only an implementation can — order, limits, and choices.

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

## 4.6.1 Cite, do not paraphrase

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

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

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

## 4.6.2 What the manual adds

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

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

## 4.6.3 Where the split falls

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

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

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

---

# 4.7 Citing a Manual

_Peios / Advanced Peios / Conventions / Technical Reference Manuals_

> The reverse direction — when a specification may defer to a manual, how the deferral must read, and KACS as the standing case.

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

## 4.7.1 When it is allowed

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

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

## 4.7.2 Say that it is a deferral

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

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

## 4.7.3 The standing case

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

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

---

# 5.1 Section Addressing

_Peios / Advanced Peios / Conventions / Citation and Addressing_

> Where a § number comes from — the book's own structure — and how stable it is across revisions.

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_

> Citing within a book, between books, and into code — and why the § reference, not the prose beside it, is the durable part.

## 5.2.1 Within a book

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

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

## 5.2.2 Between books

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

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

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

## 5.2.3 Referring to a whole book

```markdown
All identifiers in this chapter conform to PCDS.
```

## 5.2.4 Prose and the reference are different things

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

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

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

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

## 5.2.5 In code and in commit messages

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

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

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

---

# 5.3 Granularity

_Peios / Advanced Peios / Conventions / Citation and Addressing_

> The article is the finest addressable unit, why the clause-level scheme was retired, and how to write an article worth citing.

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:

```markdown
the duplicate-key rule of §5.9
```

## 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_

> A published book must contain no reference that fails to resolve — how to check, and the two traps that survive a careless check.

## 5.4.1 Every reference resolves

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

## 5.4.2 Checking

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

After a structural change, an author SHOULD:

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

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

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

## 5.4.3 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_

> What earns an appendix, why an appendix and the body must not duplicate each other, and how generated appendices are handled.

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

## 6.1.1 Appendices and the body do not duplicate

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

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

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

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

## 6.1.2 What earns an appendix

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

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

## 6.1.3 Generated appendices

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

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

---

# 6.2 Tables and Enumerations

_Peios / Advanced Peios / Conventions / Shared Style_

> Every exhaustive table has exactly one home, subsets are labelled as subsets, and disagreeing copies are resolved rather than averaged.

## 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_

> What a note carries that the surrounding text cannot — the reasoning behind a rule — and what a note must not be used for.

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

```markdown
> [!NOTE]
> Text.
```

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

## 6.3.1 What a good note does

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

## 6.3.2 What a note is not

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

## 6.3.3 Density

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

---

# 6.4 Formatting

_Peios / Advanced Peios / Conventions / Shared Style_

> The mechanical conventions — line length, frontmatter, headings, code identifiers, text hygiene and spelling.

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`:

```yaml
---
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_

> Each document uses its own reader's vocabulary, divergences are tabulated once per book, and structures are distinguished from the constants that select them.

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, and the citation format for referring to them.

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_

> What PCDS defines — GUID, LUID, SID and the Security Descriptor family — and what it deliberately leaves to the Peios Kernel TRM.

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 normative keywords PCDS uses, and the shared conventions it inherits from the PCSA conventions book.

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_

> The 128-bit GUID — its four-field binary layout, byte order, and the nil GUID sentinel.

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.

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

## 2.1.1 Nil GUID

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

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

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

---

# 2.2 String Format

_Peios / Advanced Peios / PCDS / GUID_

> The braced 8-4-4-4-12 hex form of a GUID, its exact 38-character length, lowercase output, and case-insensitive parsing.

## 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.

> [!NOTE]
> Example. Given the 16 bytes (in storage order):
>
> ```
> 04 03 02 01  06 05  08 07  09 0a  0b 0c 0d 0e 0f 10
> ```
>
> Data1 = 0x01020304 (little-endian), Data2 = 0x0506, Data3 = 0x0708.
> The string representation is:
>
> ```
> {01020304-0506-0708-090a-0b0c0d0e0f10}
> ```

## 2.2.2 Case

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

## 2.2.3 Parsing

Parsers MUST accept both uppercase and lowercase hex digits.

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

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

---

# 2.3 Comparison

_Peios / Advanced Peios / PCDS / GUID_

> GUID equality is byte-for-byte on the binary form; no total ordering is defined, and a specification that needs one must define its own.

## 2.3.1 Equality

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

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

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

## 2.3.2 Ordering

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

---

# 2.4 Generation

_Peios / Advanced Peios / PCDS / GUID_

> Peios GUIDs are RFC 4122 version 4 — filled from a cryptographic random source, with the version and variant bits fixed afterwards.

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

## 2.4.1 Algorithm

To generate a version 4 GUID:

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

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

> [!NOTE]
> In terms of bit manipulation on the binary layout:
>
> ```
> Data3 = (Data3 & 0x0fff) | 0x4000
> Data4[0] = (Data4[0] & 0x3f) | 0x80
> ```

## 2.4.2 Randomness source

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

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

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

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

---

# Appendix 2.A Prior Art

_Peios / Advanced Peios / PCDS / GUID_

> Where the Peios GUID comes from — MS-DTYP §2.3.4, RFC 4122 and DCE RPC — and the one place Peios normalises differently.

## 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_

> The 64-bit LUID — LowPart and HighPart, its boot-scoped uniqueness, and the nil 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.

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

## 3.1.1 Nil LUID

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

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

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

---

# 3.2 Comparison

_Peios / Advanced Peios / PCDS / LUID_

> LUID equality compares both halves, and monotonic allocation within a boot must not be read as a temporal ordering across sources.

## 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 are kernel-allocated, unique within a boot session, strictly monotonic, and never fabricated by userspace.

LUIDs MUST be allocated by the kernel.

## 3.3.1 Uniqueness scope

Each LUID MUST be unique within a single boot session.

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

## 3.3.2 Monotonicity

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

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

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

## 3.3.3 Fabrication prohibition

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

---

# Appendix 3.A Prior Art

_Peios / Advanced Peios / PCDS / LUID_

> The LUID derives from MS-DTYP §2.3.7, diverging in one detail — HighPart is unsigned where MS-DTYP has it signed.

## 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_

> The variable-length SID — revision, identifier authority, and up to fifteen sub-authorities, with the size limits that bound it.

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.

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

---

# 4.2 String Format

_Peios / Advanced Peios / PCDS / SID_

> The S-R-I-S string form of a SID — how each component is rendered, and the rules for parsing one back.

SIDs are represented in string form as:

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

Where:

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

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

---

# 4.3 Comparison

_Peios / Advanced Peios / PCDS / SID_

> SID equality is an exact binary match — no case folding, no normalisation, no equivalence relation — and no total ordering is defined.

## 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 fixed SIDs every implementation must recognise — universal and creator authorities, NT Authority, BUILTIN, integrity and trust labels, confinement and capability SIDs.

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. |

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

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

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

| 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. |

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

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

| 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` = None
- `512` = Protected
- `1024` = Isolated

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

## 4.4.9 Confinement SIDs (S-1-15)

| 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_

> The SID derives from MS-DTYP §2.4.2, keeping both the packet encoding and the S-R-I-S string convention.

## 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_

> The Security Descriptor — owner, group, DACL and SACL, the self-relative binary layout, the sixteen control flags, and null DACL against empty DACL.

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

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

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

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

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

- **SACL** (System Access Control List) — an ordered list of ACEs that define system-level policy. Despite the name, the SACL carries several distinct ACE types:
  - Audit ACEs — which access attempts to log.
  - Mandatory label ACE — the object's integrity level for MIC.
  - Resource attribute ACEs — name-value attributes for conditional ACE evaluation.
  - Scoped policy ID ACEs — references to central access policies.
  - Process trust label ACE — the object's PIP trust level.

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

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

## 5.1.1 Binary format

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

| 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_

> The 8-byte ACL header and the contiguous ACE array it introduces, with the rules for parsing a self-delimiting ACE sequence.

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

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

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

---

# 5.3 Access Masks

_Peios / Advanced Peios / PCDS / Security Descriptor_

> The 32-bit access mask — its object-specific, standard, special and generic right regions, the reserved bits, and generic mapping.

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.

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

---

# 5.4 ACE Types

_Peios / Advanced Peios / PCDS / Security Descriptor_

> Every ACE type and body layout — the single-SID, object, callback and resource-attribute families, the AceType constants, and the ACL revision rules.

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_ACE`
- `ACCESS_DENIED_ACE`
- `SYSTEM_AUDIT_ACE`
- `SYSTEM_ALARM_ACE`
- `SYSTEM_MANDATORY_LABEL_ACE`
- `SYSTEM_SCOPED_POLICY_ID_ACE`
- `SYSTEM_PROCESS_TRUST_LABEL_ACE`

Layout:

| 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:

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

### 5.4.2.2 Object ACE family

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

- `ACCESS_ALLOWED_OBJECT_ACE`
- `ACCESS_DENIED_OBJECT_ACE`
- `SYSTEM_AUDIT_OBJECT_ACE`
- `SYSTEM_ALARM_OBJECT_ACE`

Layout:

| 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:

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

### 5.4.2.3 Callback ACE family

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

- `ACCESS_ALLOWED_CALLBACK_ACE`
- `ACCESS_DENIED_CALLBACK_ACE`
- `SYSTEM_AUDIT_CALLBACK_ACE`
- `SYSTEM_ALARM_CALLBACK_ACE`
- `ACCESS_ALLOWED_CALLBACK_OBJECT_ACE`
- `ACCESS_DENIED_CALLBACK_OBJECT_ACE`
- `SYSTEM_AUDIT_CALLBACK_OBJECT_ACE`
- `SYSTEM_ALARM_CALLBACK_OBJECT_ACE`

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

| 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.
- `ApplicationData` MAY be empty. Semantics for empty or malformed callback payloads are defined by the relevant subsystem.
- For conditional ACEs, `ApplicationData` carries the conditional expression bytecode defined in the Conditional ACE Bytecode Reference.

### 5.4.2.4 Resource attribute ACE

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

| 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.
- `ApplicationData` MUST 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. |

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

## 5.4.4 SACL ACE types

### 5.4.4.1 Audit ACEs

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

| 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)

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

| 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.

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

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

---

# 5.5 ACE Ordering

_Peios / Advanced Peios / PCDS / Security Descriptor_

> Canonical DACL order and why it is load-bearing — first-writer-wins means position decides the outcome — plus the ordering rules for a SACL.

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

## 5.5.1 Canonical ordering

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

The canonical order is:

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

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

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

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

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

## 5.5.2 SACL ordering

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

---

# 5.6 SD Inheritance

_Peios / Advanced Peios / PCDS / Security Descriptor_

> How inheritable ACEs propagate from a container to a new child — the inheritance flags, CREATOR OWNER and CREATOR GROUP substitution, and the algorithm itself.

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:

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

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

### 5.6.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_

> The owner's implicit READ_CONTROL and WRITE_DAC, the OWNER RIGHTS ACE that suppresses them, and the rules for transferring ownership.

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_

> ACEs that carry a boolean expression — three-valued evaluation, attribute sources, SID matching, and the limits an expression must stay within.

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.

> [!NOTE]
> KACS makes virtual groups (S-1-3-4 for owner, S-1-5-10 for PRINCIPAL_SELF) visible to conditional expressions. `Member_of({S-1-3-4})` returns TRUE when the active SID-matching view treats the caller as the owner. Normal evaluation, restricted evaluation, and confinement evaluation may supply different virtual-group views as defined in their respective sections. This is an intentional divergence for semantic consistency between the SID matcher and the expression evaluator.

> [!NOTE]
> KACS promotes between INT64 and UINT64 for relational operators: negative INT64 is always less than any UINT64. Without promotion, UINT64 claims are unusable in conditions because the bytecode encodes integer literals as signed.

## 5.8.7 Binary format

Conditional expressions are encoded as a stack-based bytecode program in reverse Polish notation. The binary format is defined by MS-DTYP §2.4.4.17.4 and MUST be byte-compatible.

The expression bytecode begins with a 4-byte magic: `0x61 0x72 0x74 0x78` ("artx"). If the magic is absent or the expression is shorter than 4 bytes, evaluation MUST return UNKNOWN.

Evaluation succeeds only if the final stack contains exactly one tri-state result. If evaluation ends with zero entries, more than one entry, or a raw non-boolean value still on the stack, the expression MUST evaluate to UNKNOWN.

The full operator bytecodes and literal encodings are specified in §5.11. KACS implementations MUST be byte-compatible with MS-DTYP §2.4.4.17.4.

## 5.8.8 Limits

Implementations SHOULD enforce a maximum evaluation stack depth (recommended: 1024) and SHOULD return UNKNOWN for expressions that exceed it. Any bounds violation during parsing (reading beyond the expression buffer, underflowing the stack, integer overflow) MUST return UNKNOWN.

---

# 5.9 Claim Attribute Format

_Peios / Advanced Peios / PCDS / Security Descriptor_

> The CLAIM_SECURITY_ATTRIBUTE_RELATIVE_V1 entry — supported types, value encodings, single- and multi-entry containers, and validation.

KACS v0.20 uses a Windows-compatible `CLAIM_SECURITY_ATTRIBUTE_RELATIVE_V1`
entry format for:

- resource attributes in `SYSTEM_RESOURCE_ATTRIBUTE_ACE`
- token `user_claims`
- token `device_claims`
- `local_claims` passed to AccessCheck

The claim entry format itself is shared across all four surfaces. When multiple
entries are carried in one buffer (token claims or `local_claims`), KACS wraps
the Windows-compatible entry format in a simple length-prefixed sequence so the
buffer can be parsed deterministically without external metadata.

## 5.9.1 Supported types

KACS v0.20 supports these claim value types:

| 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 integer
- `UINT64`: unsigned 64-bit integer
- `BOOLEAN`: unsigned 64-bit integer, normalised at resolution time:
  - `0` = false
  - any non-zero value = true

### 5.9.3.2 STRING

For `STRING`, each `ValueOffsets[i]` points to a 4-byte `u32` named
`StringOffset`. `StringOffset` then points to the actual UTF-16LE
null-terminated string.

Strings are stored without a separate length field. The terminating UTF-16
null (`0x0000`) MUST appear within the containing claim entry.

### 5.9.3.3 SID

For `SID`, each `ValueOffsets[i]` points to a 4-byte `u32` named `SidOffset`.
`SidOffset` then points to a binary SID in the standard SID wire format.

### 5.9.3.4 OCTET

For `OCTET`, each `ValueOffsets[i]` points to a 4-byte `u32` named
`OctetOffset`. `OctetOffset` then points to:

| 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_len` MUST be non-zero.
- `entry_len` MUST fit entirely within the containing buffer.
- `entry_bytes` is one complete claim entry using the layout above.
- The parser consumes entries sequentially until the containing buffer length is
  exhausted exactly.

## 5.9.6 Validation rules

- The fixed header and `ValueOffsets[]` array MUST fit within the entry.
- Every offset and nested offset MUST remain within the entry bounds.
- Every string name and string value MUST terminate within the entry.
- Every referenced SID MUST be structurally valid.
- A malformed claim entry invalidates the containing surface:
  - malformed resource attribute ACE payload -> malformed SD for AccessCheck
  - malformed token claim buffer -> invalid token spec
  - malformed `local_claims` buffer -> invalid AccessCheck input

`ValueCount = 0` is valid. Empty attributes normalise to absent at resolution
time, as defined in §5.8.

---

# 5.10 Resource Attributes

_Peios / Advanced Peios / PCDS / Security Descriptor_

> Name-value metadata carried in the SACL for conditional ACEs to read — what resource attributes are, how they are stored, and which are protected.

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_

> The postfix bytecode a conditional expression compiles to — magic signature, literal and operator tokens, and the integer encoding.

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({})` and `Device_Member_of({})` return TRUE (vacuous truth).
- `Member_of_Any({})` and `Device_Member_of_Any({})` return FALSE.
- The `Not_*` forms are the logical inverse of those results.

## 5.11.5 Logical operator tokens

Logical operators test the logical value of operands and produce TRUE, FALSE, or UNKNOWN. The logical value of an operand is determined by:

- Literal-origin value → error (entire expression returns UNKNOWN)
- Attribute with null value → UNKNOWN
- Attribute with integer value → TRUE if nonzero, FALSE if zero
- Attribute with string value → TRUE if non-empty, FALSE if empty
- Result value → the result's tri-state value

### 5.11.5.1 Unary logical operators

| Token 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.

> [!NOTE]
> This byte-code table is reproduced from MS-DTYP §2.4.4.17.4 through §2.4.4.17.8 for specification self-containment. KACS-specific evaluation semantics (three-valued logic tables, INT64/UINT64 promotion, virtual group visibility in Member_of, polarity-aware SID matching) are defined in §5.8.

---

# Appendix 5.A Prior Art

_Peios / Advanced Peios / PCDS / Security Descriptor_

> Where the SD family comes from in MS-DTYP — the self-relative descriptor, the ACL and ACE formats, claim entries, and conditional bytecode.

## 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_

> What PGSS is — the cross-platform standards a system must implement to be Peios — and the test that separates a standard from a protocol.

This document defines the **Peios Generic System Standards (PGSS)**: the
cross-platform protocols and standards a system MUST implement in order
to be Peios.

A standard belongs in this document when three things are true of it.

**It is a conformance requirement.** A system that does not offer the
protocol, at the specified path, with the specified semantics, is not
Peios. Each standard here is a bar to clear, not a recommendation to
weigh.

**It belongs to no implementation.** A PGSS standard describes a contract
between two roles, not the behaviour of a particular program. Mainline
ships an implementation of each role; nothing in this document depends on
that implementation, or describes it.

**Either role may be replaced.** A third party MAY ship its own
implementation of one role — or of both — and interoperate with the other
unchanged. A standard that cannot survive that substitution is a
description of one system rather than a contract between two, and does
not belong here.

For each standard, this document covers:

- the channel it is offered on, and the access control governing it
- message framing, encoding, and the rules under which the format may be
  extended
- the messages exchanged, their fields, and the order in which they are
  exchanged
- the obligations binding on each role, including what a role MUST
  establish for itself rather than believe from a message
- the conformance requirements for each role

This document does not cover:

- How an implementation reaches the answers it gives — that is precisely
  what different systems exist to do differently
- The binary structures these protocols carry — defined in PCDS
- Protocols spoken across the kernel boundary — defined in PSPK
- Protocols between foundational userspace components — defined in PSPU
- Interfaces particular to one Mainline component — defined in the
  specification of the component that offers them

## 1.1.1 Distinguishing a standard from a protocol

The anthology's three protocol documents are told apart by what happens
when you disagree with one.

Disagreeing with a standard in this document means shipping something
that is not Peios.

Disagreeing with PSPK or PSPU means shipping a system built from
different parts — a different kernel subsystem, or a different set of
userspace components. That is a design choice, not a conformance
failure.

> [!NOTE]
> The consequence of the second and third properties together is that
> conformance is checked against the protocol, never against Mainline. A
> system that speaks a standard correctly conforms to it, whatever it
> runs behind the channel and however it reaches its answers.

---

# 1.2 Conventions

_Peios / Advanced Peios / PGSS / Introduction_

> The normative keywords PGSS uses, and the shared conventions it inherits from the PCSA conventions book.

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_

> What PGSS Logon specifies and who its parties are — obtaining a token from an authority, resolving identity, and the boundaries the authority does not cross.

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.

> [!NOTE]
> Mainline's authority is `authd`, which federates authentication to
> separate principal-source processes over PSI (PSPU §2). That
> architecture is
> *one* way to satisfy this chapter. An authority that consults a single
> built-in database, or a remote directory, or a hardware token reader,
> conforms equally well provided it speaks this protocol correctly.

## 2.1.1 What the authority does not do

An authority MUST NOT be a process factory. It does not fork the
principal's shell, does not learn about controlling terminals,
environments, or session leadership, and does not decide what the caller
does with the token it receives.

The caller installs the token and proceeds. This keeps the most
privileged process on the system out of the business of launching
arbitrary programs, and it means a client can obtain a token for a
purpose the authority need never have anticipated.

> [!NOTE]
> This is why `AccessGranted` carries a descriptor and a session
> identifier and nothing else. There is no field describing what should
> happen next, because nothing about what happens next is the
> authority's concern.

## 2.1.2 Authentication and derivation

Two acts, deliberately separated.

**Authentication** establishes that the principal is who they claim. Its
output is an identity and nothing more.

**Derivation** constructs the token: which SIDs it carries, which
privileges, at what integrity level, with what projected identifiers.
Its inputs are the authenticated identity *and local policy*.

The separation matters because the second is where a machine's own rules
apply. An identity established elsewhere — by a directory, by a remote
authority — does not carry entitlements onto this machine with it. What
that identity *means* here is decided here, every time, by the authority
applying local policy.

An authority MUST perform derivation itself. It MUST NOT accept a token,
a privilege set, or an integrity level supplied by any other party,
whatever its trust level.

## 2.1.3 The token this chapter does not describe

This chapter governs the conversation and the delivery of its result. It
does not govern the *contents* of the token that results. What SIDs,
privileges, and integrity level a principal receives is derivation, and
derivation is the authority's judgement applied to local policy.

A caller that receives a token from this protocol has been told who it
may act as. It has not been told, and MUST NOT infer, anything further
about how that conclusion was reached.

---

# 2.2 Terminology

_Peios / Advanced Peios / PGSS / Logon_

> Terms this chapter borrows unchanged from the Peios Kernel TRM and PCDS, and the one it has to qualify — logon type.

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 rather than a call — who may speak when, how it is bounded, and how it ends.

A logon is a conversation, not a call. The client opens it, the
authority asks for whatever the principal's policy requires, and the
authority ends it.

```
client                                   authority
  |                                          |
  |------------- LogonStart ---------------->|
  |                                          |
  |<--------- CredentialRequest -------------|   round 1
  |---------- CredentialResponse ----------->|
  |                                          |
  |<--------- CredentialRequest -------------|   round 2 (if required)
  |---------- CredentialResponse ----------->|
  |                                          |
  |<-- AccessGranted (+ token fd) -----------|   terminal
  |         or AccessDenied                  |
```

## 2.3.1 Why a conversation

The value of this shape is that **the client stays generic**. It does
not know what a password is. It renders the prompts it is given,
collects the answers, and returns them.

That is what makes multi-factor authentication,
password-expiry-forces-change, smartcards, or a policy that asks for a
second factor only from an unfamiliar host, changes to *authorities*
rather than to every client on the system. A protocol that named its
credential kinds would have to be revised, and every client rebuilt, for
each one.

Most logons are one round: the authority asks for everything the
principal's policy requires, in one array, and decides. The
conversational shape exists for what one round cannot express — choosing
between authentication paths, or a shared account where one credential
unlocks a requirement for another.

## 2.3.2 Sequence rules

A conversation MUST proceed as follows.

1. The client sends exactly one `LogonStart`. It MUST be the first
   message. An authority MUST reject any conversation that opens with
   something else.
2. The authority sends zero or more `CredentialRequest` messages. Each
   MUST be answered by exactly one `CredentialResponse` before the
   authority sends anything further.
3. The authority sends exactly one terminal message, `AccessGranted` or
   `AccessDenied`.
4. 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. Peer identity comes from the kernel, and every asserted field is a proposal to be constrained.

Nothing a client sends is trusted. This section states the rule once,
because every field in §2.7 is subject to it.

## 2.4.1 Peer identity

An authority MUST establish the connected peer's identity from the
**connected socket**, by reading the peer's token from the kernel. It
MUST NOT take the peer's identity from any message body, because there
is no field in which a client could put it that the client could not
also lie in.

An authority MUST NOT use `SO_PEERCRED` for this purpose. It answers a
similar-looking question and is the wrong answer: it returns the
*projected* UID, which cannot distinguish an authenticated principal
from an unauthenticated process running under the same projection, and
carries none of the token's SIDs, groups, integrity level or privileges.

> [!NOTE]
> On Peios many principals project to the same identifier, so a
> projected UID frequently identifies nobody in particular. Two
> different principals can share one. The peer token is the only thing
> that answers "who is this?" exactly.

## 2.4.2 Logon type

`LogonStart.logon_type` is a **proposal**, not an instruction.

Only the caller knows whether an inbound connection is an interactive
shell or a batch command, so the caller has to be the one to say. But an
authority MUST constrain the proposal against what that verified peer is
permitted to request. Otherwise anything that can reach the socket can
mint itself an interactive session, and the logon type — which access
control decisions depend on — becomes a value chosen by the least
trusted party in the exchange.

## 2.4.3 Identifier

The identifier names the principal a logon is *for*. It is a claim about
who is being authenticated, and it is what the subsequent credential
exchange exists to test. An authority MUST NOT treat an identifier as
established until authentication has succeeded.

## 2.4.4 Everything else

`tty` and `remote_host` are unverified context. An authority MAY record
them, MAY use them in policy, and MUST NOT treat them as established
facts. A client that lies about its remote host is not prevented from
doing so by this protocol.

## 2.4.5 Access to the socket

Access to the logon socket MUST be controlled by a security descriptor.

It MUST NOT be controlled by process integrity level. PIP *can* gate a
socket, but using it here would require every future caller — a
graphical greeter, a web console, a remote access daemon — to be signed
at high trust merely to *collect a password*. Collecting a credential is
not a privileged act; deciding whether it is correct is, and that
decision happens on the other side of the socket.

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_

> /run/logon.sock — the normative path, its access control, one conversation per connection, and why the channel does not multiplex.

## 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.

> [!NOTE]
> A protocol whose connections are long-lived and shared reaches the
> opposite conclusion, and multiplexes. PSI does (PSPU §2.7), and so
> does this chapter's own identity channel (§2.14). The difference is not
> inconsistency: a logon client's connection exists for one logon and
> is discarded, so paying for multiplexing here would buy nothing and a
> correlation identifier would only add something to forge.

## 2.5.4 Concurrency

An authority MUST serve conversations concurrently. A logon that stalls
— a principal who walks away mid-prompt — MUST NOT prevent other logons
from proceeding.

An authority MUST bound the number of conversations it will serve at
once, and MUST bound the time a conversation may remain open. Both are
policy; neither is fixed here.

## 2.5.5 Descriptor passing

The channel MUST support ancillary data (`SCM_RIGHTS`). The token is
transferred as a file descriptor alongside `AccessGranted` — see §2.9.

---

# 2.6 Message Framing

_Peios / Advanced Peios / PGSS / Logon_

> The header both channels share — magic, version, message type and size limit — and how a message body may be extended.

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 `u32` byte count, and
  the bytes follow immediately.
- **Structures and array elements** are prefixed with a `u32` byte count
  of the *whole structure*, and a decoder MUST skip to the structure's
  declared end after reading the fields it knows.

The second is what makes the format extensible.

## 2.6.7 Body extensibility

The body is a single length-framed structure, and **every structure and
every array element within it is length-framed too**.

A decoder reads the fields it knows and then skips to the structure's
declared end. A field appended by a newer peer is therefore stepped over
rather than mistaken for the next field — which is what makes the format
extensible *inside arrays*, where ignoring trailing bytes at the message
level would not help.

A structure that ends before a field an older encoder never wrote is not
an error. A decoder that reaches the end of a structure's body where an
optional trailing field would have been MUST substitute that field's
documented default (§2.7, §2.9) rather than failing.

The rules this keeps working under are binding on anyone revising this
chapter:

- Fields are **appended only**. Never reordered, never removed, never
  changed in meaning.
- A new field MUST be **optional with a safe default**, because older
  peers will not send it and will not read it.
- Adding a value to an enumeration is a **breaking change** and requires
  a version bump, because every peer is required to understand every
  value it is sent.

The last rule is the one that surprises people. An unknown enumeration
value cannot be safely ignored: a client that skipped an unrecognised
credential type would silently fail to collect something the authority
required, and an authority that skipped an unrecognised logon type would
grant the wrong kind of session.

There are exactly 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_

> The client's opening message — logon type, identifier, tty and remote host, and the credential types it can collect.

`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_

> CredentialRequest and CredentialResponse — messages, prompts, the client capability rule, and what an empty request means.

## 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.

> [!NOTE]
> A client that cannot collect an answer — the principal pressed escape
> — MAY send a response omitting it rather than closing the connection.
> That gives the authority the opportunity to deny cleanly with a reason
> the principal can read.

---

# 2.9 AccessGranted

_Peios / Advanced Peios / PGSS / Logon_

> The terminal success message — the token passed as a file descriptor and never named, the session id, and the profile that comes with it.

`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.

> [!NOTE]
> A client is not obliged to *use* any of this. Falling back to a fixed
> shell, or ignoring `display_name` entirely, is conforming. The fields
> exist so that a client which wants them does not need a second
> protocol.

## 2.9.4 What the client does next

Installs the token, if that is what it wanted. The authority is not
involved and MUST NOT be told (§2.1).

A client that decides not to install the token MUST close the
descriptor. A logon session whose token is never installed still exists
as far as the kernel is concerned.

A client SHOULD NOT fail a logon because a `home` it was given does not
exist. The principal has authenticated and holds a token; refusing to
start their session over a missing directory turns a cosmetic problem
into being locked out. Starting elsewhere and saying so is the better
failure.

---

# 2.10 AccessDenied

_Peios / Advanced Peios / PGSS / Logon_

> The terminal failure message — a deliberately small denial vocabulary, and what a denial must never reveal.

`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.

> [!NOTE]
> An authority MAY record the distinction in an audit trail an
> administrator can read. What it must not do is return it to the
> caller.

---

# 2.11 Credential Types

_Peios / Advanced Peios / PGSS / Logon_

> What a credential type tells a client to collect, why the registry is kept short, and why credentials cross the socket in the clear.

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.

> [!NOTE]
> A one-time code is collected exactly as a password is — a line of
> text, not echoed — so it needs no new type. An authority asks for it
> with `credential_type = Password` and a `credential_name` of
> "Verification code". A hardware token that requires a challenge to be
> relayed to a reader *would* need a new type, because no existing
> collection method produces the answer.
>
> This is the test to apply: does an existing client, understanding only
> the types it already has, collect the right thing? If yes, no new type
> is needed.

## 2.11.1 Adding a type

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_

> The obligations that make plaintext on the wire defensible — bounded lifetime, zeroing, and what must never reach a log.

These bind both roles. They are stated normatively because plaintext on
the wire (§2.11) is only defensible if its lifetime is short.

## 2.12.1 Bounding lifetime

An implementation MUST hold credential material in memory that is
**erased before it is released**, and the erasure MUST NOT be removable
by an optimising compiler.

The obligation extends to **the encoded message**, not only to the
credential field. A `CredentialResponse` holds the password just as much
as the answer inside it does, and a buffer wiped at one level while
another copy is dropped unerased achieves nothing.

An implementation MUST erase:

- the buffer credential material was read into;
- any buffer it was copied into during encoding or decoding, including
  an intermediate allocation abandoned by a buffer that grew;
- any structure holding it once the conversation reaches a terminal
  state.

The middle clause is the one an implementation is most likely to miss.
A growable buffer that reallocates while a message is being encoded
leaves a complete copy of everything written so far in the abandoned
allocation, and erasing the buffer that survives does not touch it.
Reserving the encoded size before writing avoids the problem entirely.

## 2.12.2 What this does not promise

It guarantees that *these* buffers are erased before their memory is
reused. It cannot guarantee anything about copies made elsewhere — a
string the caller parsed a credential out of, a register or stack slot
the optimiser chose, a terminal's own input buffer. Those belong to
whoever made them.

Nor does it defend against a hostile kernel, a core dump, or swap. Those
are addressed elsewhere: process integrity protection, and disabling
core dumps for the authority.

## 2.12.3 Logging and diagnostics

An implementation MUST NOT write credential material to a log, an audit
record, an error message, or a debugging aid.

A type carrying credential material SHOULD render, under whatever debug
formatting its language provides, as a redaction rather than as its
contents. A stray diagnostic print is the most common way material
escapes a process that was otherwise careful, and the defence is to make
the careless thing produce nothing useful.

## 2.12.4 Collection

A client MUST collect a `Password` without echoing it. Where it cannot
establish that echo is suppressed, it MUST NOT collect the credential
anyway.

A client MUST NOT silently truncate an answer. `data` admits 32768 bytes
(§2.8); a client whose collection method admits fewer MUST fail the
conversation rather than send a prefix of what the principal supplied,
which would present as a wrong credential and leave the remainder in
whatever buffer it was read from.

## 2.12.5 Timing

An authority MUST NOT allow the *time* it takes to reach a denial to
distinguish an unknown principal from a bad credential (§2.10).

This is more demanding than it looks with a memory-hard verifier,
because the natural implementation returns immediately when a principal
does not exist and spends tens of milliseconds when one does. An
authority MUST perform equivalent work in both cases — verifying against
a decoy verifier that no credential matches is the usual construction.

> [!NOTE]
> A decoy derived from randomness at start-up, rather than from a
> constant, is not merely unguessable but not stable between boots
> either. An implementation built this way runs exactly one verification
> on both paths, including for a principal that requires no credential
> at all.

---

# 2.13 Identity Lookup

_Peios / Advanced Peios / PGSS / Logon_

> The second half of the chapter — turning an identity you already hold into something displayable, on a second socket rather than a second standard.

Sections 2.3 to 2.12 specify how a caller obtains a token. The remainder
of this chapter specifies how any program on the system turns an
identity it already holds into something it can display or compare: a
name into a SID, a SID into a name, a POSIX identifier into either.

It exists because a Linux program calls `getpwuid` and has never heard
of a token. Without this surface, every principal an authority mints
appears throughout the system as a bare number.

## 2.13.1 Why the authority answers this

An authority may federate identity to separate sources, and a source
counts POSIX identifiers **relative to a range the authority assigns
it**. The authority adds the base. No message in either direction
carries an absolute identifier to or from a source: a source asserts
relative numbers, and is asked by relative number or by SID.

The arithmetic that makes a number absolute therefore exists in exactly
one place, and only that place can invert it. No source can be asked
"who is uid 1001000", because no source is ever asked an absolute
number at all. The authority is not merely a convenient place to put
this — it is the only party the protocol permits to answer.

The same holds for names. A bare name may exist in more than one source,
and which one wins is a property of the system rather than of any source
in it (§2.15).

> [!NOTE]
> An authority that keeps all identity in one built-in database still
> satisfies this, and for it the rebasing question does not arise. The
> requirement is on the answer, not on how the answer is reached — as
> everywhere else in this chapter.

## 2.13.2 A second socket, not a second standard

Identity lookup is served on `/run/ident.sock` (§2.14), separately from
`/run/logon.sock`, by the same authority speaking the same framing
(§2.6).

The separation is **not** for isolation. One authority answers both, so
a defect or a hang in either reaches the other regardless; a second
socket buys nothing there and this chapter does not pretend otherwise.

It is for **admission**. A listening socket has one accept queue. A
single directory listing is thousands of lookups and a filesystem walk
is millions, where logons are a handful per boot — so a shared socket
would let an ordinary `find` fill the queue that an administrator needs
in order to sign in and stop it. Two sockets means the two populations
of caller cannot starve each other, whatever load either is under.

The second reason is access control. The set of programs that may
originate a logon is small and enumerable; the set that may look up a
name is every program on the system. Those want different security
descriptors, and a descriptor is a property of a socket.

## 2.13.3 Not a conversation

A logon is stateful: the connection *is* the conversation, and needs no
correlation identifier (§2.5).

A lookup is not. Requests are independent, a connection carries as many
as a client cares to send, and replies MAY return in an order other than
the one requests arrived in. Each request therefore carries a `tag` that
its reply echoes (§2.14).

> [!NOTE]
> That is what allows an authority to satisfy several outstanding
> requests from one connection together — against a remote source, in a
> single query — without a delay window that would tax a caller waiting
> on a single answer. Nothing here requires it, and an authority that
> answers strictly in order, one request at a time, is fully conformant.

## 2.13.4 Not authentication

No credential ever crosses this socket. There is no message with which a
client could offer one and none with which an authority could ask.

Everything here returns the kind of data a POSIX system has historically
kept in a world-readable file. An authority MAY nonetheless restrict
individual fields (§2.16), and the request names the fields it wants
precisely so that it can.

## 2.13.5 Not in this version

**Privileges, integrity levels, owner and default DACL are not returned
by any message here.** They are not identity: nothing stores them, and
an authority computes them from local policy at the moment it derives a
token (§2.1).

They are, however, deliberately *reserved* rather than excluded. An
authority that cannot be asked what a policy would produce can only be
verified by signing someone in and observing the result. A future
revision is expected to add a distinct request of the shape:

```
Evaluate { key, logon_type } -> { privileges, integrity, owner, default_dacl }
```

— a token that is derived and then discarded. It is parameterised by
logon type because the answer genuinely depends on it: an authority adds
SIDs reflecting *how* a principal signed in (§2.16), so "what privileges
does this principal have" has no single answer.

A revision MUST NOT instead add privileges or integrity as fields of
`Lookup` (§2.16). A field of a record describes something a source
holds; this does not.

---

# 2.14 The Identity Channel

_Peios / Advanced Peios / PGSS / Logon_

> /run/ident.sock — its path, access control, framing, multiplexing and bounds, and why it carries no descriptors.

## 2.14.1 Socket

An authority MUST listen on a `SOCK_STREAM` Unix domain socket at:

```
/run/ident.sock
```

The path is normative, for the same reason `/run/logon.sock` is: a name
resolver linked into every process on the system cannot be asked to find
it by configuration.

An authority MUST listen on both sockets. Offering one without the other
is not conformance — a system whose principals cannot be named is as
broken as one whose principals cannot sign in.

## 2.14.2 Access control

The socket MUST carry a security descriptor granting connect access to
every principal that runs ordinary programs.

This is the opposite posture to `/run/logon.sock`, and deliberately so.
A descriptor that withheld connect access would not protect anything: it
would make `ls -l` print numbers for the principals it excluded, while a
principal who *can* connect learns the same names either way.
Restriction, where an authority wants it, belongs on individual fields
(§2.16) — not on reaching the socket at all.

## 2.14.3 Framing

Messages use the header, magic, version, size limit and body
extensibility rules of §2.6 without modification. The message types
served here are disjoint from those of the logon socket (§2.A).

A message of one socket's range arriving on the other MUST be refused.
An authority MUST NOT serve a `Lookup` received on `/run/logon.sock`,
and MUST NOT serve a `LogonStart` received on `/run/ident.sock`.

> [!NOTE]
> Sharing the magic is what makes that a clean refusal rather than a
> parse failure. A peer connected to the wrong socket gets "this message
> is not served here" from byte six, instead of discovering the mistake
> several fields into a structure it has misread.

## 2.14.4 Multiplexing

A connection carries any number of requests. A client MAY have more than
one outstanding at a time, and an authority MAY answer them in any
order.

Every request carries a `tag`, a `u32` chosen by the client, and its
reply carries the same value. The tag is **in the message body**, not
the header, so that the header of §2.6 is unchanged.

A client MUST NOT reuse a tag while a request bearing it is outstanding.
An authority MUST echo the tag it received and MUST NOT interpret it
otherwise.

A client MUST NOT assume replies arrive in request order. An authority
that answers strictly in order is conformant; a client that depends on
it is not.

> [!NOTE]
> The contrast with §2.5 is deliberate rather than inconsistent. A logon
> connection exists for one logon and is discarded, so multiplexing
> would buy nothing and a correlation identifier would only add
> something to forge. A lookup connection is opened by a name resolver
> that may issue thousands of requests, and the identifier costs four
> bytes.

## 2.14.5 Concurrency and bounds

An authority MUST serve connections concurrently, and MUST NOT let a
slow answer on one connection delay answers on another.

An authority MUST bound the number of connections it will accept and the
number of requests it will hold outstanding per connection, and SHOULD
refuse further requests on a connection that exceeds the second rather
than closing it.

An authority MUST NOT wait indefinitely for a source. A request that
cannot be answered within the authority's own bound MUST be answered
`Unavailable` (§2.18). The bound is on the request, not on each source
consulted: an authority that walks several sources in turn MUST NOT let
the total exceed what it would have allowed one.

## 2.14.6 No descriptor passing

Nothing is transferred over this channel by descriptor. An authority
MUST ignore ancillary data received here.

## 2.14.7 Peer identity

An authority that restricts any field (§2.16) MUST establish peer
identity from the connected socket's peer token, as §2.4 requires, and
MUST NOT take it from a message body.

An authority that restricts no field MAY skip establishing peer
identity, since it would make no decision with it.

---

# 2.15 Names and Resolution

_Peios / Advanced Peios / PGSS / Logon_

> A name is opaque to the client — comparison, reserved characters, qualified names, and the search order an authority applies.

## 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.

> [!NOTE]
> The ASCII restriction is not an assumption that names are English. It
> is a decision to defer confusable and normalisation handling rather
> than get them wrong: `jack` spelled with a Cyrillic `а` renders
> identically to `jack` and is a different principal, and the same name
> in NFC and NFD is two byte sequences that a comparison will call two
> people. Relaxing this later is backward compatible; tightening it
> later would mean renaming principals that already exist.

## 2.15.4 Qualified names

A name MAY be qualified with a realm, written `name@realm`.

**No realm syntax is defined in this version.** An authority MUST refuse
a name containing `@`, and the character is reserved so that a principal
literally named `jack@local` cannot come into existence before the
syntax does.

Nothing here changes when realms arrive: a qualified name is a different
string in the same field, parsed by the same authority. That is why the
field is one opaque string rather than a structured pair — a structure
would have put the parsing in the client, which the first rule of this
section forbids.

## 2.15.5 Search order

A bare name is resolved by consulting sources in an order that is
**local configuration of the authority**.

An authority MUST resolve in a configured order, MUST NOT derive that
order from the order in which sources registered, and MUST stop at the
first source that answers.

> [!NOTE]
> Registration order is whatever the boot happened to do. Making it
> decide who `jack` is would mean a slow disk could change which
> principal a name refers to.

## 2.15.6 Answers an authority holds itself

An authority MAY answer from its own knowledge, without consulting any
source. Well-known principals and groups — those whose SIDs are fixed by
the system rather than issued by anybody — are the ordinary case: their
names, and 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 that answers every question a name resolver asks — keys, fields, memberships, and the present/withheld distinction.

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.

> [!NOTE]
> POSIX keeps users and groups in separate namespaces, so `getpwnam` and
> `getgrnam` can be asked the same string and expect different objects.
> Carrying the kind on the request is what lets both be served correctly
> without requiring an authority to forbid the collision.

### 2.16.1.3 fields

A bitmask of the attributes the reply should carry. Identity is not
among them: every successful reply carries the SID, the canonical
qualified name, and the kind actually found, and a client cannot decline
them.

| 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.

> [!NOTE]
> Field bits are the one place in this chapter where an enumeration may
> grow without a version bump (§2.6). It is safe here, and only here,
> because the reply says which fields it is answering: an older
> authority ignoring a newer bit is reported as not implementing it,
> rather than silently omitting something a client believed it had asked
> for.

Requesting only what is wanted is not primarily a bandwidth measure — a
`getpwuid` wants nearly every field anyway. It matters for the caller
that resolves a dozen SIDs to render a security descriptor and wants
only names, and it is the seam at which an authority can restrict
individual fields without restricting the socket (§2.14).

## 2.16.2 LookupReply

`msg_type` = `0x8010`. Authority to client.

| 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 in `values`;
- **listed in `withheld`** — with a reason, below;
- **in neither** — this authority does not implement the field.

The third state is a statement about the authority, not about the
object. An authority that implements a field MUST place every request
for it in one of the first two states, whatever happened underneath: a
field an authority supports but could not obtain is `Absent`,
`Declined`, `Restricted` or `TooLarge`, never silence. Reporting it as
neither would tell the client the authority cannot answer that question
at all, and a client is entitled to stop asking.

A withheld entry is a length-framed structure:

| 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:

- **`Declined`** where the source will not enumerate the group's
  members.
- **`TooLarge`** where the membership exceeds what one reply can carry.
- **`Absent`** where the group is not one that has recorded members at
  all.

That last case is not a limitation. A group's membership may be
**recorded** — held by a source, as a local group's is — or it may be a
**rule** an authority applies when it derives a token. Nothing records
who belongs to `Everyone`; an authority adds it to every token it mints.
Groups reflecting *how* a principal signed in are further still from a
recorded membership: they are properties of a logon rather than of a
principal, and the same principal is in one at a console and not over a
network.

An authority MUST report `Absent` for such a group rather than
manufacturing a list, and MUST NOT report `Declined`, which would
suggest an answer exists somewhere.

> [!NOTE]
> This asymmetry is deliberate and follows the shape of the data. "Which
> groups is this principal in" is answered by every source cheaply,
> because it is how memberships are stored. "Who is in this group" needs
> an index in the other direction, over a set that may be the whole of
> an organisation — historically the reliable way to make a directory
> server fall over from a filesystem listing.

## 2.16.4 One round trip

An authority MUST be able to answer each of the following in a single
request:

| Caller 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_

> Walking every principal or every member of a large group, with a cursor and no completeness guarantee.

`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.

> [!NOTE]
> Members are a field of `Lookup` for the common case and a mode of
> `Enumerate` for the large one, rather than a message of their own. The
> field keeps `getgrnam` to one round trip where the group is small,
> which is nearly always; this keeps the large case correct without a
> third request type or a partial member list, which would be worse than
> no list at all.

### 2.17.1.2 cursor

Empty on the first request. On a continuation it MUST be the `next`
returned by the immediately preceding reply.

A cursor is **opaque**. A client MUST NOT construct, parse or modify
one, and MUST NOT present one to a different authority or after
reconnecting.

An authority MUST reject a cursor it did not issue, or one it can no
longer honour, with `Malformed` (§2.18). It MUST NOT silently restart
the enumeration, and MUST NOT answer with an empty page and an empty
`next`: the first hands the caller a second copy of the beginning under
the impression it is continuing, and the second reports a truncated walk
as a complete one.

## 2.17.2 EnumerateReply

`msg_type` = `0x8011`. Authority to client.

| 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.

> [!NOTE]
> A short list that looks complete is the failure this field exists to
> prevent. An administrator reading an account listing has no way to
> tell a machine with four principals from a machine whose directory did
> not answer, and the difference decides whether they are looking at the
> whole picture.

## 2.17.4 No completeness guarantee

An authority MUST NOT be required to enumerate.

A source may hold more principals than a reply, a page, or an
administrator's patience can carry, and a source backed by a remote
directory may be able to answer any single question while being quite
unable to answer all of them. `incomplete` is the honest outcome, not a
degraded one.

A caller MUST NOT treat enumeration as a way to test whether a principal
exists. `Lookup` answers that, exactly, at any scale.

---

# 2.18 Outcomes

_Peios / Advanced Peios / PGSS / Logon_

> The outcome every identity-channel reply carries — NotFound, Unavailable and Refused — and why these are not a security boundary.

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.

> [!NOTE]
> The rule binds even where an authority does not cache, because its
> clients may. A name resolver is entitled to remember an answer for the
> length of a process, and it can only do that safely if the two are
> distinguishable.

## 2.18.2 Unavailable and the search order

An authority MUST consult sources in the configured order and MUST stop
at the first that answers, so an unreachable source **later** in the
order does not make an answer `Unavailable`. It was never going to be
asked.

An unreachable source **earlier** in the order does, even if a later one
holds a matching name — because the earlier source is the one whose
answer would have won.

> [!NOTE]
> Returning the later source's principal in that situation would be
> worse than failing. The name would resolve to a different SID than it
> does when the system is healthy, so access decisions would be made
> against the wrong principal precisely while something is broken.

## 2.18.3 Refused

Reserved. No field in this version is restricted by default, and an
authority that restricts none will never send it.

It exists because the field mask (§2.16) is where restriction belongs,
and a request refused in its entirety needs an answer that is not
`NotFound`. An authority restricting a *field* MUST use the `Restricted`
withheld reason instead, and MUST still answer the rest of the request.

Because it is reserved, `Refused` MUST NOT be used for anything else. A
source that will not answer, a mode an authority has not implemented, or
a question it cannot serve are not the caller lacking permission, and
reporting them as `Refused` tells a client to stop asking on behalf of
this caller when the answer would be the same for every caller. Those
outcomes are `Unavailable`, `Absent`, or `Malformed` as the case
requires.

An authority MUST NOT use `NotFound` in place of `Refused` or of a
restricted field. Concealing a principal's existence from a caller that
may not read their shell protects nothing — the SID is already in the
file listing that prompted the lookup — and would make an authorization
decision indistinguishable from an empty system.

## 2.18.4 Timing

The prohibition in §2.10 on distinguishing an unknown principal from a
bad credential does not apply here. There is no credential, and
`NotFound` is an ordinary answer that this chapter states plainly.

An authority MUST NOT allow the *presence* of an answer in a cache to be
observable to a caller that would be refused the answer itself. Where no
field is restricted, nothing is refused, and the requirement is vacuous.

---

# 2.19 Conformance

_Peios / Advanced Peios / PGSS / Logon_

> Every requirement of this chapter collected by role, for an implementation claiming to be a PGSS Logon authority or client.

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

1. Listen on `/run/logon.sock` as a `SOCK_STREAM` Unix domain socket
   (§2.5).
2. Control access to it with a security descriptor, not with process
   integrity and not with POSIX permission bits (§2.4).
3. Serve conversations concurrently, so that one stalled logon does not
   block others (§2.5).
4. Support `SCM_RIGHTS` on the channel (§2.9).

### 2.19.1.2 Framing

5. Reject any message whose magic is not `PGSL`, as a whole-connection
   failure (§2.6).
6. Reject any message whose version it does not implement, answering
   `UnsupportedVersion` where it can still encode a denial (§2.6).
7. Reject any message declaring more than 65536 bytes, or fewer than a
   header, without reading the body (§2.6).
8. 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

9. Require `LogonStart` as the first message, and reject a conversation
   opening otherwise (§2.3).
10. Send exactly one terminal message, nothing after it, and close the
    connection (§2.3).
11. Bound the number of rounds, the time spent awaiting an answer, and
    the time a conversation may remain open, and terminate with
    `ConversationLimit` on exhausting any of them (§2.3, §2.5). A bound
    reached MUST produce a terminal message; closing the connection
    silently is what the code exists to prevent.
12. Reject a `CredentialResponse` it did not solicit (§2.3).
13. 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

14. Establish peer identity from the connected socket's peer token,
    never from a message body, and never via `SO_PEERCRED` (§2.4).
15. Treat `logon_type` as a proposal and constrain it against the
    verified peer (§2.4).
16. Treat `identifier`, `tty` and `remote_host` as unverified claims
    (§2.4).

### 2.19.1.5 Derivation

17. Perform derivation itself, and never accept a token, privilege set
    or integrity level from another party (§2.1).
18. Never fork or exec on the client's behalf; never learn about
    terminals, environments or session leadership (§2.1).

### 2.19.1.6 Prompting

19. 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).
20. Ensure `credential_ref` is unique among the prompts of one request
    (§2.8).

### 2.19.1.7 Result

21. Transfer the token as a file descriptor in the ancillary data of
    `AccessGranted`, never by name (§2.9).
22. Never distinguish an unknown principal from a bad credential — by
    denial code, by reason text, or by timing (§2.10, §2.12).
23. Send `home` and `shell` as absolute paths, or empty, whatever their
    provenance; an authority with no value for a profile field MUST
    leave it empty rather than invent one (§2.9).

### 2.19.1.8 Credential handling

24. 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).
25. Never write credential material to a log, audit record, error
    message or diagnostic (§2.12).

### 2.19.1.9 Identity lookup

26. Listen on `/run/ident.sock` as well as `/run/logon.sock`, as a
    `SOCK_STREAM` Unix domain socket (§2.14).
27. Grant connect access to that socket, by security descriptor, to
    every principal that runs ordinary programs (§2.14).
28. Refuse a message of one socket's range received on the other
    (§2.14).
29. Echo each request's `tag`, and never interpret it otherwise
    (§2.14).
30. Serve requests concurrently, and never let a slow answer on one
    connection delay another (§2.14).
31. Bound the time spent answering a request, and answer `Unavailable`
    rather than waiting indefinitely (§2.14).
32. Ignore ancillary data received on the identity socket (§2.14).

### 2.19.1.10 Resolution

33. Interpret names itself, and never require a client to parse, qualify
    or case-fold one (§2.15).
34. Compare names ASCII case-insensitively, establishing the comparison
    itself rather than delegating it to a source (§2.15).
35. 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).
36. Resolve bare names in a configured order, never in registration
    order, stopping at the first source that answers (§2.15).
37. 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

38. Never answer a `Principal` request with a group, or a `Group`
    request with a principal, and establish the kind itself rather than
    relaying a source's (§2.16).
39. Ignore a field bit it does not implement, and never report it as
    present (§2.16).
40. Report each requested field it implements as present or as withheld
    with a reason, never as neither (§2.16).
41. Withhold `MEMBERS` with a reason rather than returning a partial or
    empty list, where it will not or cannot produce it (§2.16).
42. Report `Absent` rather than `Declined` for a group whose membership
    is a rule rather than a record (§2.16).
43. Encode "no POSIX identifier" as zero, and never substitute a number
    a caller could mistake for a real one (§2.16).
44. Answer a `passwd` record, a `group` record, a principal's
    memberships, or a name for a SID, each in one request (§2.16).
45. Never report `NotFound` unless every source in the search order that
    could have answered was consulted and answered (§2.18).
46. Report `Unavailable` where a source earlier in the search order
    could not be reached (§2.18).
47. Never report `NotFound` in place of `Refused` or of a `Restricted`
    field, and never report `Refused` for anything other than a caller
    that may not make the request (§2.18).
48. List every source that did not contribute to an enumeration, and
    never report `Found` on a page it could not produce (§2.17, §2.18).
49. 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).
50. Serve member enumeration where it withholds `MEMBERS` as `TooLarge`
    (§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

1. Send exactly one `LogonStart`, as the first message (§2.3).
2. Answer each `CredentialRequest` with exactly one `CredentialResponse`
   (§2.3).
3. Never send a `CredentialResponse` that was not solicited (§2.3).
4. Treat a connection that closes without a terminal message as a failed
   logon, and not retry automatically (§2.3).

### 2.19.2.2 Framing

5. Reject any message whose magic is not `PGSL` (§2.6).
6. Reject any message whose version it does not implement (§2.6).
7. Skip to a structure's declared end after reading known fields (§2.6).

### 2.19.2.3 Capabilities

8. Never declare in `supported_credential_types` a credential type it
   cannot render (§2.7). Declaring fewer than it can render is
   permitted, and an empty list is how a client asks for a logon
   requiring no interaction.
9. Fail the conversation, rather than guess, if it receives a prompt it
   cannot render (§2.8).

### 2.19.2.4 Rendering

10. Display received messages, in order, before the prompts of the same
    request (§2.8).
11. Not interpret or branch on message text, `credential_name`, or
    `reason` (§2.8, §2.10).
12. Echo `credential_ref` back unchanged (§2.8).

### 2.19.2.5 Result

13. Read the token descriptor from the ancillary data of
    `AccessGranted`, and treat an `AccessGranted` without one as a
    failed logon (§2.9).
14. Close the descriptor if it does not install the token (§2.9).
15. Have a fallback for every `profile` field, and treat an empty field,
    and an absent `profile`, identically and not as an error (§2.9).
16. Never execute a relative `shell`, nor resolve a relative `home`
    against its own working directory (§2.9). A `shell` containing no
    separator is relative, and MUST NOT be resolved against a search
    path.
17. Never treat `profile` as a directory lookup, nor cache it as an
    answer about any principal other than the one who just authenticated
    (§2.9).

### 2.19.2.6 Credential handling

18. Collect a credential only where it can establish that its collection
    method meets the type's requirements, and never collect a `Password`
    with echo enabled (§2.12).
19. Never silently truncate an answer to fit its own buffer (§2.12).
20. 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

21. Forward a name unchanged, and never parse, split, qualify or
    case-fold one (§2.15).
22. Never reuse a `tag` while a request bearing it is outstanding, and
    never assume replies arrive in request order (§2.14).
23. Treat a cursor as opaque: never construct, parse or modify one, nor
    present one to a different authority or across a reconnection
    (§2.17).
24. Continue an enumeration until `next` is empty, never infer
    completion from an empty page, and never present a walk it abandoned
    for any other reason as a complete one (§2.17).
25. Surface an enumeration's `incomplete` list rather than discarding it
    (§2.17).
26. Distinguish `NotFound` from `Unavailable`, and never cache the
    second as though it were the first (§2.18).
27. 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_

> Every message type by number and socket, the protocol constants, and the field limits.

## 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_

> Every enumerated value the protocol carries — logon and identifier types, credentials, severities, denial codes, keys, kinds, fields and outcomes.

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_

> Where PGSS Logon sits against Windows LSA, PAM and POSIX name resolution — and which resemblances are deliberate.

## 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_

> What PSPK is — the protocols spoken across the kernel boundary between a kernel subsystem and the userspace process serving it — and how trust runs across that line.

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 normative keywords PSPK uses, and the shared conventions it inherits from the PCSA conventions book.

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_

> The KMES contract and its two roles — the kernel as sole producer, userspace as consumer — and the privilege that gates attachment.

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 one contiguous record — the packed header layout, the payload, event types, origin class, identity fields and ordering.

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_guid` is the token governing the emitting thread's
  access rights. If the thread was impersonating, this is the
  impersonation token; otherwise it equals `true_token_guid`.
- `true_token_guid` is the emitting process's primary token,
  regardless of impersonation.
- `process_guid` identifies the emitting process. It is assigned when
  the process is created and does not change across `exec`.

Any of the three MAY be the null GUID, meaning the kernel had no
identity to record — emission before the access control subsystem
initialised, or from a context with no associated process such as a
kernel worker thread. A consumer MUST treat a null identity as
"unattributed" and MUST NOT treat it as a valid GUID value that could
match a real token or process.

## 2.2.6 Ordering

Events from different CPUs are ordered by `timestamp`. Events with
identical timestamps from different CPUs were genuinely concurrent and
have no defined relative order.

Within a single CPU, `sequence` is the ordering primitive. It is
monotonic across wall clock discontinuities, which `timestamp` is not:
a clock adjustment can move timestamps backwards, and a consumer that
requires monotonic ordering within a CPU MUST use `sequence`. Events
with identical timestamps on the same CPU are ordered by `sequence`.

Each CPU numbers independently and there is no global sequence. The
counter starts at zero when the kernel module loads and is incremented
before a value is taken, so the first event on a CPU carries sequence
number 1 and **sequence 0 is never assigned**. The pair
(`cpu_id`, `sequence`) uniquely identifies an event within one boot.

A gap in the sequence for a given CPU means events were lost — either
overwritten before the consumer read them, or dropped by the kernel
before they reached the buffer. Sequence numbers are continuous across
a buffer replacement, so a generation change does not itself produce a
gap.

---

# 2.3 Attaching and Mapping

_Peios / Advanced Peios / PSPK / KMES Event Stream_

> How a consumer attaches to a per-CPU ring, the double mapping that makes wrapping invisible, and the producer metadata page.

## 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_

> The consumer protocol — draining a ring, detecting loss, waiting for notification, handling buffer replacement, and the memory ordering it all rests on.

A consumer typically dedicates one thread to each buffer. Each thread
independently drains its buffer, sleeps when the buffer is empty, and
re-attaches when the buffer is replaced. The protocol uses no locks
and, while events are available, no system calls.

Each consumer keeps its own `read_pos` in its own memory. On first
attaching to a buffer, a consumer SHOULD set `read_pos` to the
buffer's current `tail_pos`, which starts it at the oldest surviving
event.

## 2.4.1 Draining

1. Load `write_pos` with acquire ordering. If `write_pos == read_pos`,
   no events are available: go to the notification wait.
2. Load `tail_pos` with acquire ordering. If `read_pos < tail_pos`,
   the events at the read position have been overwritten and the
   consumer has been lapped: set `read_pos = tail_pos`. The skipped
   span is lost, and will show up as a sequence gap.
3. Save the current `tail_pos` as `saved_tail`.
4. Read the event at data region offset `read_pos & (capacity - 1)`.
5. Re-read `tail_pos`. If it has advanced past `saved_tail` and
   `read_pos < tail_pos`, the event was overwritten while it was being
   read. The bytes just read MUST be discarded: go to step 2.
6. Check that `event_size > 0` and `event_size >= header_size`. If
   either fails, the bytes are not a valid event; the consumer SHOULD
   set `read_pos = tail_pos` and go to step 2. A consumer MUST perform
   this check: an `event_size` of zero would otherwise make the drain
   loop spin forever.
7. Process the event. Advance `read_pos` by the event's `event_size`.
   Go to step 1.

A consumer MUST NOT read beyond an event's `event_size` boundary.

Steps 3 and 5 are what make a lock-free read safe against a producer
that is overwriting the region being read. A consumer that omits the
re-read can process a torn event assembled from two different events'
bytes.

## 2.4.2 Detecting loss

A consumer SHOULD track the last sequence number it processed for each
CPU. A gap means events were lost, whether because they were
overwritten before being read or because the kernel dropped them
before they reached the buffer. The size of the gap is the number of
events lost.

Loss is a normal condition under load, not an error: the buffer
preserves recent events at the cost of old ones. A consumer SHOULD
report loss rather than treating it as fatal.

## 2.4.3 Notification wait

When a buffer is empty:

1. Store 1 to `need_wake` with release ordering.
2. Re-load `write_pos` with acquire ordering. If events arrived
   between the drain loop finding the buffer empty and this store,
   clear `need_wake` to 0 and return to the drain loop. This re-check
   is REQUIRED: without it, an event written in that window would find
   `need_wake` still clear, and the consumer would sleep with events
   waiting.
3. Read `futex_counter`.
4. Optionally spin, re-checking `write_pos`. If events arrive during
   the spin, clear `need_wake` to 0 and return to the drain loop. The
   spin duration is the consumer's choice, and a consumer MAY omit
   this step entirely.
5. Call `futex_wait(&futex_counter, last_seen_value)`, where
   `last_seen_value` is the value read in step 3. The kernel puts the
   thread to sleep only if `futex_counter` still holds that value, so
   a wake that arrived in the meantime is not missed.
6. On waking, clear `need_wake` to 0 and return to the drain loop.

The futex address is the `futex_counter` field in the mapped producer
metadata page. This is a **shared** futex, keyed by the page's backing
inode, and a consumer MUST wait on it as such: a wait issued with
`FUTEX_PRIVATE_FLAG` will never be woken.

Clearing `need_wake` to 0 in steps 2, 4, and 6 MAY be a relaxed store.
If KMES reads a stale set value after the consumer has cleared it, it
performs a wake on a thread that is already awake, which is harmless.

Under sustained load a consumer never reaches the notification wait:
it stays in the drain loop, `need_wake` stays 0, and the producer's
notification cost is a single byte read per event.

## 2.4.4 Buffer replacement

KMES replaces every buffer when its configured capacity changes.
Replacement preserves as many surviving events as the new capacity
allows and keeps sequence numbering continuous, but it invalidates
positions: the events are re-compacted from position 0 in the new
buffer, so the consumer's `read_pos` means nothing there.

After each drain cycle — the buffer emptied, or a batch limit reached
— a consumer SHOULD read `generation`. If it differs from the value
last seen:

1. Record the sequence number of the last event successfully processed
   from this buffer.
2. Finish draining the old buffer up to its `write_pos`, which is now
   frozen: KMES has stopped writing to it. A consumer MUST complete
   this drain before switching, or it loses every event written
   between its read position and the switchover.
3. Call `kmes_attach` for the same CPU to obtain a descriptor for the
   replacement buffer, and map it.
4. Read the new buffer's `capacity`, `write_pos`, and `tail_pos`.
5. Scan the new buffer for the first event whose sequence number is
   greater than the recorded one, and set `read_pos` to that event's
   position. A consumer MUST locate its position by sequence number
   and MUST NOT carry `read_pos` across.
6. Close the old descriptor and unmap the old buffer.
7. Resume draining from the new buffer.

The old buffer's pages remain valid for as long as any consumer keeps
them mapped, so a consumer is never racing to finish before the memory
disappears.

If the new capacity is large enough to hold everything that survived
in the old buffer, no events are lost across the replacement. If it is
smaller, the oldest surviving events are discarded until the remainder
fits — the same overwrite semantics applied against the smaller
capacity — so loss is bounded to the oldest part of the buffer and
appears as a sequence gap.

A consumer sleeping on the old buffer is woken when the replacement
happens, provided its `need_wake` was set, so it observes the
generation change rather than sleeping indefinitely on a buffer that
will never receive another event.

## 2.4.5 Memory ordering

| 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_

> How a binary is signed so the Peios kernel will accept it, who the signer and verifier are, and why signing is a contract rather than an implementation detail.

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_

> The fixed 3310-byte signature blob — where it is stored, the lookup order, what is covered, and how a key is selected and trusted.

## 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.

1. Read the first four bytes. A file shorter than four bytes, or whose
   first four bytes are not `\x7fELF`, is not ELF: go to step 3.
2. Parse the ELF structures and scan for a section named
   `.peios.sig`. **Once such a section header is found, the ELF path
   is committed**: the extended attribute MUST NOT be consulted,
   whatever happens next. A wrong type, a wrong size, an out-of-range
   offset, a read failure, a bad version byte or a failed verification
   all yield "unsigned". A structural failure encountered while
   parsing MUST commit the path in the same way. The single exception
   is `e_shnum == 0`, which does not commit.
3. Read `security.peios.sig`. If present and exactly 3310 bytes, use
   it.
4. Otherwise the file is unsigned.

The commitment rule is a security requirement rather than an
optimisation. Without it, an attacker able to write a malformed ELF
section could force fallback to whichever location they more easily
controlled.

Where both locations are populated, the ELF section wins. A signer
SHOULD NOT populate both.

## 3.2.3 What is signed

The message is a 32-byte SHA-256 content hash. Which bytes it covers
depends on where the signature is stored, and a signer MUST use the
form matching its chosen location.

**ELF section source.** The hash covers the file with the section's
*contents* replaced by zeros:

```
SHA-256( file[0 .. sh_offset)
       || 0x00 × sh_size
       || file[sh_offset + sh_size .. file_size) )
```

Only the section **contents** are zeroed. The `Elf64_Shdr` entry
describing the section is hashed verbatim, as are the ELF header, the
program headers, the section-name string table and every other byte of
the file. The section header metadata is therefore integrity-protected
along with everything else, which is what stops an attacker relocating
or resizing the signature section without invalidating the signature.

This has a direct consequence for how a signer MUST work. The complete
file layout — including `sh_offset`, `sh_size`, `sh_name` and the
position of the section header table — MUST be final before the hash
is computed. The practical sequence is to reserve a 3310-byte
`.peios.sig` section filled with zeros, finalise the layout, hash the
file as it then stands, sign the hash, and write the blob into the
reserved bytes without touching anything else. Because the reserved
region is already zero, the file as hashed and the file as shipped
differ only in those 3310 bytes.

**Extended attribute source.** The hash covers the entire file with no
exclusions:

```
SHA-256( file[0 .. file_size) )
```

This applies to ELF files reaching the attribute path as well as to
non-ELF ones. The ELF-zeroed form is used **only** when the ELF
section is the signature source.

In both cases `file_size` is a snapshot taken at the start of
verification. A verifier MUST re-check the size before returning and
MUST discard the result if it changed.

## 3.2.4 Algorithm

The signature is **ML-DSA-65** as specified in FIPS 204. The public
key is 1952 bytes and the signature is 3309 bytes.

Signing is:

```
ML-DSA.Sign(private_key, content_hash, ctx = "")
```

and verification is the corresponding `ML-DSA.Verify`.

This is **pure** ML-DSA — FIPS 204 Algorithm 2 and Algorithm 3 — and
not HashML-DSA, the pre-hashing variant. The message happens to be a
32-byte SHA-256 hash, which pure ML-DSA signs directly. A signer MUST
NOT use the pre-hashing variant; a signature produced that way will
not verify.

The context string MUST be empty. Signers MUST NOT set a context.
Verifiers cannot express one, so a signature produced under a
non-empty context simply fails to verify rather than being detected
and reported.

It follows that ML-DSA's context field is **not** available for domain
separation between binary signatures and any other Peios signature
system. Separation MUST come from using distinct keys.

A signer MUST supply the public key as the **raw** 1952-byte key, not
an SPKI-wrapped DER encoding. From OpenSSL 3.5 or later, the raw key
is the trailing 1952 bytes of the DER public key.

## 3.2.5 Key selection and trust tiers

The blob carries no key identifier, so a verifier selects a key by
exhaustive trial: it tries each key in its table in order and takes
the first that verifies.

The trust tier is a property of **which key verified** — a `pip_type`
and a `pip_trust`, both unsigned integers — and never of anything the
signer encoded. A signer cannot express intent about the tier it
wants. It obtains whichever tier the verifier associates with the key
it used, and if the verifier carries no matching key the file is
simply unsigned.

Consequently:

- A signer that wants software to run at a tier MUST have its public
  key present in the verifier's table. Getting a key into that table
  is a deployment question, not a format question.
- A signer MUST NOT assume its signature is portable across systems
  carrying different key tables. The same file may be trusted on one
  and untrusted on another, with no observable difference in the file.
- Verification cost is linear in the number of keys, so a verifier MAY
  reasonably carry few.

---

# 3.3 The PIP Contract

_Peios / Advanced Peios / PSPK / Binary Signing and PIP_

> What a verified signature confers — a PIP type and trust level — how dominance is decided, and what may and may not be relied on.

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_

> The RSI contract between the kernel's registry subsystem and the userspace sources that store data for it — and what a source is not.

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_

> Attaching to /dev/pkm_registry — the privileges the open handler demands, registration, source slots, and resuming a Down slot.

## 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 `CurrentUser` in any casing, which is reserved;
- the route identity of each hive — its case-folded name paired with
  its scope — does not collide with one held by an Active source
  (`EEXIST`);
- no root GUID is all-zero, and the root GUIDs within this request are
  distinct from each other;
- a hive without `RSI_HIVE_PRIVATE` carries an all-zero `scope_guid`;
- `hive_count` is within `MaxHivesPerSource` and the registered source
  count is within `MaxRegisteredSources` (`ENOSPC`);
- `max_sequence` is not `U64_MAX`, since the kernel MUST be able to
  allocate above it (`EOVERFLOW`).

`max_sequence` initialises the kernel's global sequence counter to at
least one above it, so that new writes always outrank anything already
persisted. A source MUST report it accurately; under-reporting it
allows a new write to collide with a stored entry.

## 4.2.3 Source slots

A successful registration creates a **source slot**, the kernel object
owning one connection and its hive set. A slot is Active or Down.

Each registered hive has a stable identity: its case-folded name, its
visibility, its scope GUID if private, and its root GUID.

A source crash or an fd close marks the slot Down. It does **not**
unregister the source or retire any hive identity. Down slots keep
their identities reserved, and collision checks include them. There is
no implicit retirement.

While a slot is Down its hives are unavailable; operations needing a
round trip fail, key descriptors held by processes remain valid, and
watches remain armed.

## 4.2.4 Resuming a Down slot

A new process MAY take over a Down slot. It MUST hold `SeTcbPrivilege`
and it MUST register **exactly the same hive set**: the same number of
hives, and for each of them the same case-folded name, the same
visibility, the same scope GUID and the same root GUID.

Partial resume is rejected. In particular:

- A request whose only mismatch is a different root GUID for an
  otherwise-matching hive fails `ESTALE`.
- Other partial or malformed resume attempts fail `EINVAL`.
- A collision with an **Active** slot fails `EEXIST`, and takes
  precedence over `ESTALE` when both would apply.

A source MUST NOT expect to add hives by resuming a Down slot with a
larger set; that is a partial-resume failure. New hives require a new
slot.

The kernel authenticates a replacement by `SeTcbPrivilege`, not by
process identity. Process identity cannot survive a crash and restart,
so nothing records or compares it.

On a successful resume the slot becomes Active and the kernel replays
any pending layer deletions before resuming normal traffic.

## 4.2.5 Before serving anything

A source MUST purge orphaned key records before completing
registration. An orphaned record is a key with no path entry in any
layer, left behind by a key that was unlinked but not dropped before
the previous shutdown.

If that cleanup cannot be completed, the source MUST fail registration
rather than become Active with known orphans. The kernel does not
verify this and cannot: it has no independent view of the source's
storage.

On first boot against an empty store, a source MUST create a root key
record for each hive it backs, generating a GUID for each and giving it
an appropriate default Security Descriptor, and MUST persist them. The
root GUIDs it then reports in registration are those. Subsequent
startups reuse the persisted ones.

---

# 4.3 Message Framing

_Peios / Advanced Peios / PSPK / Registry Source Interface_

> The 22-byte request header and its response, the binary encoding, the asymmetry in how each side may be extended, and the concurrency rules.

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_code` that is not the request's with the response bit set;
- a response written on a descriptor that is not the slot's active one.

A source that cannot frame its own messages correctly is not one whose
other answers can be relied on. Ordinary per-operation errors are
reported through the status vocabulary and do not tear anything down.

**`poll()`** reports the fd readable when at least one complete request
is queued, writable while the slot is Active, and `POLLHUP | POLLERR`
when the slot is Down or the fd is closing. An fd that is open but not
yet registered reports nothing.

## 4.3.6 Concurrency and timing

A source MAY process requests in any order and MUST handle multiple
in-flight requests without head-of-line blocking. Responses are matched
by `request_id`, not by arrival order.

The kernel bounds in-flight requests per source by
`MaxConcurrentRSIRequests`, default 256.

A source MUST respond to **every** request it has read, exactly once,
even if the kernel-side caller has already given up. The kernel applies
a request timeout — `RequestTimeoutMs`, default 30 seconds, measured
from the moment it first tries to reserve an in-flight slot and
covering the whole wait — and a source is **not** disconnected for
exceeding it. Late responses are validated and processed exactly like
on-time ones.

A timed-out request remains in the kernel's in-flight table, and keeps
occupying one of the source's slots, until the source answers or the
connection is torn down. A source that accumulates unanswered requests
will exhaust its own concurrency budget.

---

# 4.4 Operations

_Peios / Advanced Peios / PSPK / Registry Source Interface_

> All eighteen RSI operations — path, key, value and layer — and the rule that a source returns every layer-qualified entry without pre-filtering.

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_guid` and MUST NOT
  contribute a metadata entry.

Violating any of these is malformed data.

The kernel resolves symlink targets itself, by issuing a separate
`RSI_QUERY_VALUES` for the key's default value. A source MUST NOT
interpret the symlink flag or follow anything.

### 4.4.1.2 `RSI_CREATE_ENTRY`

Create a path entry `(parent, child_name, layer) → guid`.

**Request:** `parent_guid` (16), `child_name`, `layer_name`,
`child_guid` (16), `sequence` (`u64`).

**Response:** status only.

Always paired with `RSI_CREATE_KEY`, which carries the same GUID. The
kernel sends `RSI_CREATE_ENTRY` **first**, so that a losing race is
detected before a key record is created: an `RSI_ALREADY_EXISTS` here
means another writer got the name.

### 4.4.1.3 `RSI_HIDE_ENTRY`

Create a HIDDEN path entry at `(parent, child_name, layer)`.

**Request:** `parent_guid` (16), `child_name`, `layer_name`,
`sequence` (`u64`).

**Response:** status only.

### 4.4.1.4 `RSI_DELETE_ENTRY`

Remove the path entry at `(parent, child_name, layer)`, whether it was
a GUID entry or a HIDDEN one.

**Request:** `parent_guid` (16), `child_name`, `layer_name`.

**Response:** status only.

### 4.4.1.5 `RSI_ENUM_CHILDREN`

Enumerate every child entry under a parent, across all layers.

**Request:** `parent_guid` (16).

**Response:** `child_count` (`u32`), then per child:

| 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_

> The status vocabulary, the obligations not tied to one operation, what the kernel validates, and where the trust boundary falls.

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_

> The byte stream representing a registry key and everything beneath it with full layer fidelity, its two roles, and the constraints that shaped it.

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_

> Record framing, field encoding, ordering, and how the format is versioned and extended.

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:

- `HEADER` MUST be the first record and MUST appear exactly once.
- Every `LAYER` record MUST precede all key data. A `LAYER` record
  after key data has begun is invalid.
- The **root** `KEY` record — the one whose GUID equals
  `HEADER.RootGUID` — MUST be the first `KEY` record in the stream, and
  MUST appear exactly once.
- Within a key's section, records MUST appear in the order
  `PATH_ENTRY`, then `VALUE`, then `BLANKET_TOMBSTONE`. A `PATH_ENTRY`
  after a `VALUE` or `BLANKET_TOMBSTONE` in the same section is
  invalid, as is a `VALUE` after a `BLANKET_TOMBSTONE`.
- No `PATH_ENTRY`, `VALUE` or `BLANKET_TOMBSTONE` may appear before the
  first `KEY` record.
- `TRAILER` MUST be the last record. Any record after it is invalid.

## 5.2.4 Which section a path entry belongs to

A `PATH_ENTRY` naming a key belongs to **that key's** section: it is
one of the incoming entries for the key whose section it is in.

A HIDDEN entry has no key, so it cannot have a section of its own. It
belongs to the section of the key that is its **parent**, alongside
that key's other records. A HIDDEN entry masking a name where no key
exists in any layer is still valid — it expresses that a layer hides a
name, whatever else is or is not there.

A writer MUST NOT emit a GUID-bearing `PATH_ENTRY` in the **root**
key's section. On restore, the target key's existing name is
authoritative and such a record would be discarded. A reader MUST skip
one rather than treat it as an error.

A path entry's parent GUID may belong to a key that only has path
entries in a different layer. The merged-tree walk handles that; it is
not a special case.

## 5.2.5 Versioning

`HEADER` carries two version numbers.

**`FormatVersion`** is the version the stream was written with.

**`MinReaderVersion`** is the oldest reader that can process the stream
correctly. A reader MUST reject a stream whose `MinReaderVersion`
exceeds its own supported version, before acting on any of it.

The current version is 21 in both fields, and readers support 21.

A writer that used only older features SHOULD set a lower
`MinReaderVersion`, so that older readers can restore the stream. A
writer MUST raise `MinReaderVersion` when a new record type is required
for a correct restore, so that an older reader refuses the stream
rather than restoring an incomplete one.

## 5.2.6 Extension

Extension is by **new record types only**.

Unknown record types MAY appear anywhere between `HEADER` and
`TRAILER`. When `MinReaderVersion` permits, a reader MUST skip them,
and MUST treat them as inert: they do not begin or end a key section,
do not satisfy any required record, do not declare a layer, and do not
affect root mapping, sequence remapping or any validation rule. They
**do** count toward `TRAILER.RecordCount` and they **are** covered by
the checksum.

A record payload MUST be consumed **exactly**. Trailing bytes inside a
record of a known type are invalid, even though `record_len` would
accommodate them. A reader MUST NOT skip unrecognised trailing data
within a known record, and a writer MUST NOT add any.

This is deliberately the opposite of the RSI's request convention. A
stream is replayed into mutations long after it was written, and a
field silently ignored there is data silently lost.

---

# 5.3 Records

_Peios / Advanced Peios / PSPK / Registry Backup Format_

> Every record type in wire order — HEADER, LAYER, KEY, PATH_ENTRY, VALUE, BLANKET_TOMBSTONE and TRAILER.

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 rather than a merge — root remapping, GUID rules, parent validation, sequence remapping, and how layers survive.

Restoring is a **replace**, not a merge. The target key's contents and
descendants are removed before the stream's contents are written.

The whole operation — teardown and rebuild together — MUST be atomic.
There is no partial-restore mode, and a store that cannot offer
atomicity cannot be a restore target.

## 5.4.1 The target key survives

A restore is performed against a key that already exists. That key
**object** is not replaced: its GUID, its parent, its name, its
volatile flag and its symlink flag remain what they were and MUST NOT
be taken from the stream.

What the stream's root `KEY` record supplies is the mutable part — the
Security Descriptor and the last write time — which MUST be written to
the target inside the restore.

The root record's immutable flags MUST **match** the target's. A backup
of a volatile key restored onto a non-volatile one, or a symlink onto a
non-symlink, MUST be rejected.

## 5.4.2 Root remapping

`HEADER.RootGUID` is stream-local. Every reference to it — a
`PATH_ENTRY`'s `ParentGUID` or `ChildGUID`, a `VALUE` or
`BLANKET_TOMBSTONE`'s `KeyGUID` — MUST be remapped to the target key's
existing GUID **before** any validation of parent references and before
any record is applied.

The backup root GUID MUST NOT be created as a new key record.

Descendant `KEY` records keep their backup GUIDs, which are written
into the target verbatim.

## 5.4.3 GUID rules

- The stream MUST contain exactly one `KEY` record whose GUID equals
  `HEADER.RootGUID`, and it MUST be the first `KEY` record.
- A non-root GUID MUST NOT appear twice in the stream.
- A non-root GUID MUST NOT equal the restore target's GUID.
- A non-root GUID that already exists **outside** the subtree being
  replaced is a collision and the restore MUST fail. A reader is not
  required to detect this before beginning; it MAY surface during
  replay, in which case the atomicity requirement ensures nothing is
  left behind.

## 5.4.4 Parent validation

Before a path entry is applied, its `ParentGUID` — after root remapping
— MUST be either the restore target's GUID or the GUID of a non-root
`KEY` record **already processed** earlier in the stream. A parent
outside the stream's remapped key set MUST cause the restore to fail.

This is what prevents a crafted backup from injecting path entries into
arbitrary parts of the existing namespace, outside the subtree being
replaced. It is not optional.

A HIDDEN `PATH_ENTRY` is held to a stricter rule: its remapped
`ParentGUID` MUST equal the GUID of the section it appears in, not
merely some already-processed key.

## 5.4.5 Creating a key

A `KEY` record carries no name and no parent, so both come from the
section's path entries.

The **anchor** is the first GUID-bearing `PATH_ENTRY` in the section,
in stream order, whose remapped `ChildGUID` equals the `KEY` record's
GUID. Its remapped `ParentGUID` and its `ChildName` are the parent and
name the key is created with.

- If the section contains no GUID-bearing path entry targeting the
  `KEY` record's GUID, the restore MUST fail.
- If any GUID-bearing `PATH_ENTRY` in a non-root section targets a
  *different* GUID after remapping, the restore MUST fail.
- HIDDEN entries do not satisfy the anchor requirement. They are
  parent-owned records for the key being created and are replayed only
  after it exists.

The key's `LastWriteTime` MUST be written immediately after it is
created, before any of the section's other records are replayed.

Path entries for the **root** section are handled differently:
GUID-bearing ones are not restored, because the target key's existing
incoming path entries remain authoritative. HIDDEN entries in the root
section are parent-owned records for the restore root and MUST be
restored, after parent validation.

## 5.4.6 Sequence remapping

The backup's sequence numbers preserve its internal layer-resolution
ordering. A restore is a new mutation, and its entries MUST become
newer than everything already present while keeping that internal
order. A reader MUST NOT write backup sequence numbers through
unchanged.

Remapping MUST preserve streamability: it MUST NOT require a seekable
input or a pre-scan pass. Before the first layer-qualified record is
applied, the reader records an offset — the next sequence number it
would allocate — and then, for every restored layer-qualified record:

```
new_sequence = restore_sequence_offset + backup_sequence
```

That needs no lookahead: the running maximum is computed during the
single pass.

The offset is held stable for the duration of the restore, so that no
other sequence-allocating mutation can interleave. Reads are not
blocked by it. A restore containing no layer-qualified record at all
need not reserve one.

If a remapped value would reach or exceed `U64_MAX`, the restore MUST
fail. The valid remapped range is `[offset, U64_MAX)`; `U64_MAX` itself
is never a valid sequence number.

When the restore reaches any terminal state — success, abort, failure
or cancellation — the global counter MUST be advanced past the highest
number the restore dispatched, and that advance MUST NOT be rolled
back. Numbers a failed restore dispatched become unused gaps, exactly
like those of any other failed write.

Dispatch order remains structural stream order. It is the remapped
numbers, not the order they were sent in, that preserve the backup's
internal layer resolution.

## 5.4.7 Layers in a restored stream

`LAYER` manifest records define nothing (§5.3). If restored entries
reference a layer that is not in the live layer table, and the stream
does not also restore that layer's metadata subtree as ordinary
registry data, those entries become latent unknown-layer entries and
are ignored during resolution until real metadata exists. If the
metadata subtree **is** included, it is restored through the ordinary
path, and those records are what define the layer.

## 5.4.8 Privilege

A restore replaces every Security Descriptor in the subtree it covers,
so the privilege to perform one effectively confers descriptor control
over everything within its reach.

Before any key record is written, a reader MUST check the layer
manifest: if any declared layer has a precedence above 0, **or** any
existing layer with the same folded identity does, the caller MUST hold
the privilege that guards high-precedence layers, or the restore MUST
be aborted before a single byte is written.

Records in the stream that create or raise persisted layer metadata
above precedence 0 are subject to the same check as an ordinary write
would be. Neither check is redundant: the first covers what the
manifest declares, the second covers what the stream actually writes.

## 5.4.9 Validation before mutation

A reader MAY validate the entire stream — including the trailer's
record count and checksum — before applying any of it. Doing so is
stronger than this specification requires, and it means a corrupt
stream is rejected before anything is torn down rather than after. The
cost is memory rather than seeking, since the records must be retained
across the teardown.

A reader that instead validates as it goes MUST still guarantee that a
checksum failure leaves nothing applied, which the atomicity
requirement already demands.

---

# 1.1 Scope

_Peios / Advanced Peios / PSPU / Introduction_

> What PSPU is — the protocols and interchange formats the foundational userspace components speak to each other — and why they are not conformance requirements.

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 normative keywords PSPU uses, and the shared conventions it inherits from the PCSA conventions book.

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_

> What PSI specifies — how an authentication authority federates identity to separate processes that hold it — and its two roles.

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.

> [!NOTE]
> The distinction shows up in what happens when you disagree with each
> document. Disagreeing with PGSS Logon means shipping something that is
> not Peios. Disagreeing with this chapter means shipping an authority
> that federates differently, or does not federate at all, which is a
> design choice nobody will dispute.

---

# 2.2 Terminology

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> Terms this chapter borrows unchanged from the Peios Kernel TRM, PCDS and PGSS Logon.

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 never dials a source; sources connect inward. The most consequential shape decision in the chapter, and what it buys.

**The authority listens. Sources connect to it.** The authority never
initiates a connection to a source.

This is the most consequential shape decision in this chapter, and it is
worth being explicit about what it buys.

## 2.3.1 Why the direction matters

The authority holds the privilege to mint tokens. It is the most
privileged userspace process on the system. An authority that dialled
out would need, in its configuration, a list of paths to connect to —
and a process holding that privilege having a configurable list of
things to go and talk to is a liability out of proportion to the
convenience.

Because sources connect inward, the authority's sockets are
`accept()`-only. It never opens an outbound connection to anything, for
any reason.

## 2.3.2 What follows

**Restart is the source's problem.** A source whose connection drops
reconnects. The authority does not retry, does not queue, and does not
track sources it has not heard from. A source that has gone away is
simply not registered.

**A source is not required to exist.** An authority with no registered
sources cannot authenticate anybody, and that is a coherent state rather
than an error — it means no identity has been made available to it yet.

**Ordering is the init system's problem.** The authority must be
listening before a source can register, and a service that depends on
authentication must start after a source has. Expressing that is a
service-ordering question, not a protocol one, and this chapter says
nothing about it.

> [!NOTE]
> A source SHOULD report itself ready only once its registration has
> been *acknowledged*, so that "the source is running" and "the source
> can authenticate" are the same statement to anything ordered after it.
> This is what makes the `Registered` acknowledgement load-bearing
> despite carrying almost nothing.

---

# 2.4 Assert, Never Mint

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> A source says who somebody is and nothing else — the structural reason a compromised source cannot grant itself privilege.

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 source is an authority for its own slice of the world, which is why most of PSI is PGSS Logon — and where the two deliberately diverge.

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_

> The PSI socket, its access control, why connections are long-lived, and what happens when one fails.

## 2.6.1 Socket

An authority that federates over PSI MUST listen on a `SOCK_STREAM` Unix
domain socket.

Unlike PGSS Logon's path, this one is **not normative**. PSI is not a
conformance requirement (§2.1), and an authority that offers it may put
it where it likes provided its sources are told. Mainline's is
`/run/psi.sock`.

## 2.6.2 Access control

The socket SHOULD carry a security descriptor. It is **DoS protection
and nothing more**, and an implementation MUST be written as though it
were absent.

The reason is that the socket cannot be the boundary. What establishes a
source's identity is the peer's token (§2.9), which is checked on every
connection. A descriptor that kept casual traffic away would be a
convenience; a descriptor *relied upon* would be a second, weaker access
control that someone will eventually assume is doing the work.

An authority MUST therefore bound the number of unregistered connections
it will hold open, and the time it will wait for a registration,
independently of any descriptor.

> [!NOTE]
> An unauthorised peer is refused at the identity check, which is cheap,
> and the connection cap bounds what it can occupy in the meantime. That
> is the whole of what the descriptor's absence costs.

## 2.6.3 Long-lived connections

A source's connection persists for the life of the source and carries
every logon routed to it.

An authority MUST bound the number of registered sources and the number
of concurrent conversations per source. A source MUST bound the
conversations it will track, and MUST NOT depend on the authority's
bookkeeping to do it — a source that trusted the authority's limit would
be trusting a bound it cannot verify.

## 2.6.4 Failure

A framing error is **fatal to the connection**, not to a conversation.
Once a message has failed to parse there is no way to know where the
next one starts, so both parties MUST tear the connection down rather
than attempt resynchronisation.

A failed write is likewise fatal. A partial write desynchronises the
stream just as a bad frame does, and treating it as a per-conversation
error would leave a corrupt connection in use.

Ordinary semantic failures — an unknown principal, a bad credential, a
refused logon — are **not** connection failures. They are `Refusal`
messages (§2.13) and the connection continues.

---

# 2.7 Message Framing

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> The 20-byte header — the first twelve bytes are PGSS Logon's, unchanged — plus conversation identifiers, size limits and SIDs on the wire.

## 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 `0` is reserved** for connection-level messages:
  `Register`, `Registered` and `Changed` (§2.17). It MUST NOT be used
  for a logon or a query.
- Logon and query conversations use identifiers from 1 upward, drawn
  from one space.
- The **authority allocates** them. A source MUST NOT invent one, and
  MUST reply on the identifier it was given.
- An identifier is unique among *live* conversations on one connection.
  An authority MAY reuse one after a conversation has reached a terminal
  state.

A source MUST reject a message on a conversation it does not know, and
MUST NOT treat it as opening a new one. Only `Authenticate` (§2.11),
`Query` (§2.15) and `EnumerateSource` (§2.16) open one, and an authority
MUST NOT open one with an identifier already live.

Rejecting means declining to act on it. A source MUST NOT reply on a
conversation it does not know: an authority MAY have reused the
identifier after a terminal state, so a reply could arrive as a second
terminal message for a conversation that has already ended. Discarding
it, and recording that it happened, is the whole of the obligation.

A source MUST likewise refuse an `Authenticate`, `Query` or
`EnumerateSource` arriving on conversation `0`, which is reserved.

## 2.7.4 Size limit

A message MUST NOT exceed **81920 bytes** — larger than PGSS Logon's
ceiling, because a PSI message wraps one.

## 2.7.5 Message direction

The high bit of `msg_type` marks a message sent by **the source**. This
follows PGSS Logon's convention that the bit marks the authority for the
matter at hand: on this interface the source is the authority for its
own principals, and the logon authority is the one asking.

## 2.7.6 Encoding

PSI shares its codec with PGSS Logon, and PGSS §2.6's encoding rules
apply here unchanged: little-endian multi-byte integers; UTF-8 strings,
length-prefixed and never NUL-terminated; length-framed structures and
array elements, skipped to their declared end; `u32` element counts on
arrays, with stated maxima binding on encoder and decoder alike.

### 2.7.6.1 SIDs on the wire

SIDs are carried as **opaque bytes**, in the binary self-relative form
PCDS specifies, never as text.

They are opaque *to the codec*, which has no business knowing what a SID
is. They are emphatically not opaque to the authority, which MUST
validate every SID it receives before treating it as identity (§2.13).
The obligation to check sits in the process that mints tokens, not in
the layer that moves bytes.

A SID MUST NOT exceed 68 bytes — the eight-byte prelude plus fifteen
sub-authorities, which is the most the encoding's one-byte count admits.

## 2.7.7 Body extensibility

The extensibility rules of PGSS §2.6 apply unchanged: fields are
appended only, a new field is optional with a safe default, and a new
enumeration value is a breaking change requiring a version bump. The one
exception is the capability bitmask of §2.8, for the reason given in
§2.B.

One PSI-specific application deserves stating. `Authenticate` (§2.11)
**nests** a whole `LogonStart` inside its own length frame rather than
inlining its fields. The obvious encoding — `LogonStart`'s fields, then
PSI's — is wrong: `LogonStart` belongs to PGSS Logon and grows on PGSS
Logon's schedule, so a field appended there would silently displace the
field after it. Nesting lets the two evolve independently.

The same reasoning applies to every shared body PSI carries, and §2.C
lists them.

---

# 2.8 Registration

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> The first message on every connection — the source's name, domain, capabilities, TTL, batch limit and the identifier range it is granted.

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

1. A connection MUST open with `Register` on conversation `0`. An
   authority MUST refuse any connection that opens otherwise.
2. An authority MUST bound the time it waits for the opening `Register`,
   and close the connection on expiry. A peer that connects and says
   nothing MUST NOT be able to hold resources indefinitely.
3. An authority MUST NOT admit two sources under one name at the same
   time.
4. An authority MUST send `Registered` only after the source is
   routable, so that a logon racing the acknowledgement cannot find a
   source that is registered but not yet reachable.
5. A source MUST NOT send any other message before receiving
   `Registered`.
6. An authority SHOULD report, where an administrator will see it, that
   a source registered with no identifier range — its principals will
   all project as unmapped, and the cause is a configuration omission
   rather than anything the source did.

---

# 2.9 Establishing What a Source Is

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> The authority establishes a connecting source's identity from the kernel, never from what the source says, and checks it against an allowlist.

The authority MUST establish a connecting source's identity **for
itself**, from the kernel, and MUST NOT take it from anything the source
sends.

## 2.9.1 A source proves nothing

The mechanism worth recommending is that a source proves nothing at all,
because the init system already did.

Where the init system places a **service SID** in each service's token —
a SID derived from the service's name, which only the init system can
mint — the authority can:

1. take its list of permitted source names from its own configuration;
2. derive the service SID each of those names implies;
3. read the connecting peer's token and ask which of those SIDs it
   carries.

The resulting identity is assembled entirely from the authority's
configuration and the kernel. **Nothing is contributed by the process on
the other end.** There is no shared secret, nothing to provision,
nothing to rotate, and nothing to steal — the derivation is a pure
function of a name that only the init system can act on.

> [!NOTE]
> Mainline derives `S-1-5-80-<SHA-1 of the uppercased UTF-16LE service
> name>`, matching what the init system places in every service token.
> The derivation exists so that platform services — all running as
> SYSTEM — remain distinguishable from one another, which is exactly
> this problem.

## 2.9.2 What is deliberately not checked

**That the peer is SYSTEM.** The service SID subsumes it: the user SID
could never distinguish one platform service from another, since they
all run as the same principal. Requiring SYSTEM as well would needlessly
forbid a future source running under a lesser account, which is a
direction worth keeping open.

## 2.9.3 The allowlist

An authority MUST NOT accept a source it has not been configured to
accept.

An empty configuration MUST mean **no source may register**, not *any
source may*. An allowlist that fails open is not an allowlist. The
visible cost is that a system configured with no sources cannot
authenticate anyone, which is the correct way for that mistake to
present — loudly, at the first logon attempt, rather than silently at
the first compromise.

> [!NOTE]
> Mainline's allowlist is a registry key whose *subkey names* are the
> permitted source names, so enumerating that key answers "what may
> assert identity on this machine?" exactly, with no second list
> anywhere to drift out of step.

---

# 2.10 The Domain Claim

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> A source declares the domain it is authoritative for — how the claim is checked, why domains must be disjoint, and what it does not prove.

A source declares the domain it is authoritative for. Every principal it
may assert lives under it (§2.18).

## 2.10.1 It is a claim

A source generates or is given its own domain, so **nothing about the
SID can prove the claim is honest**. What gives it weight is entirely
what the authority does with it.

An authority MUST apply all of the following.

### 2.10.1.1 1. A domain MUST be declared

A source that declares no domain MUST be refused. There would be nothing
to confine its assertions to, which is the whole purpose of collecting
one.

> [!NOTE]
> The field is appended to a message that existed before it, so a source
> built against an older shape decodes as declaring *nothing* rather
> than as malformed. That is why the refusal is stated as its own rule:
> the diagnosis should name the real problem.

### 2.10.1.2 2. The shape MUST be checked

A domain MUST be a well-formed, locally-issued domain SID: revision 1,
the NT authority (`5`), the non-unique prefix `21`, and three further
sub-authorities — `S-1-5-21-A-B-C`, four sub-authorities in total.

The shape is the entire check, and it is enough. A source cannot claim
`S-1-5-32` (BUILTIN), or the NT authority's well-known range, or
`S-1-1-0`, because none of them has that shape. **There is no list of
forbidden domains to keep in step with the SID catalogue** — the
permitted shape excludes every one of them by construction.

### 2.10.1.3 3. Domains MUST be disjoint

No two concurrently registered sources may claim the same domain. Two
authorities for one namespace means whichever answers first decides who
a name belongs to, and the other's principals become impersonable by the
first.

### 2.10.1.4 4. A source MUST NOT change domain

A source that re-registers MUST declare what it declared before. An
authority MUST refuse a change.

This is the check that survives a source restarting, and it is worth its
cost: every other check passes for a source that is killed and comes
back compromised. It still holds the right service SID, its new domain
is still a claimable shape, and with itself deregistered there is
nothing left to collide with.

An authority MAY hold this record only for its own lifetime. Persisting
it means the authority writing state, which is a larger commitment than
the guarantee justifies.

### 2.10.1.5 5. An administrator MAY pin

An authority SHOULD allow an administrator to configure the exact domain
a named source must declare, and MUST refuse a source declaring anything
else when one is configured.

A configured pin that cannot be parsed MUST NOT be treated as absent.
Absence means *no pin*; an unparseable value means an administrator
tried to apply the control and got it wrong, and silently downgrading
that to "unconstrained" removes the control at the moment it was being
applied. An authority MUST fail towards refusing the source.

## 2.10.2 What remains uncovered

With no pin configured, and another source not currently registered, a
compromised source could declare *that* source's domain and assert its
identities. Disjointness catches it only while both are registered.

This is stated rather than solved. Closing it requires someone to write
the pin down, and an authority cannot invent that authority for itself —
writing it automatically would mean the process holding the
token-minting privilege also holding a configuration write handle, which
is a worse trade than the gap it closes.

---

# 2.11 Authenticate

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> The client's LogonStart nested whole and routed to a source, with the conversation limits that bound what follows.

`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_

> CredentialRequest and CredentialResponse carrying PGSS Logon bodies verbatim — what the authority polices on the way through, and what it never inspects.

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_

> The two terminal messages a source may send — and what an assertion pointedly does not contain: no session, no token, no privileges.

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.

> [!NOTE]
> A source that made this the one field escaping the scope check would
> have found a route to `BUILTIN\Administrators` that the group array
> denies it. The obligation in §2.21 to apply membership scope to the
> primary group closes that.

### 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

1. **Validate every SID** with a structural check before treating it as
   identity. Bytes from another process are bytes until checked, and the
   check belongs in the process that mints tokens rather than in the
   codec that moved them (§2.7). This includes SIDs carried *inside* a
   claim value.
2. **Enforce identity scope** (§2.18), **membership scope** (§2.19) —
   including over `primary_group` — and **numeric scope** (§2.20).
3. **Fail the logon** on a malformed group SID, an unusable claim, an
   invalid `canonical_name`, or a `unix_id` outside the source's range,
   rather than dropping it. Dropping would sign the principal in with
   authority the source did not state — a confusing way to be wrong at
   best, and for a claim, a silent change of the policy that will be
   applied to them. A source that cannot encode a SID is broken.
4. **Drop a logon SID from the asserted groups**, loudly. No source is
   authoritative for one: the kernel mints them per session, and this
   session's did not exist when the source answered. A source asserting
   one is either buggy or reaching for a *different* session's SID,
   which would forge membership of somebody else's logon.
5. **Drop duplicates**, first mention winning. A source asserting a
   group the authority also derives is redundant, not wrong.

Rules 4 and 5 drop rather than refuse because the token still ends up
correct, and refusing would punish a principal for a source's defect
without making anything safer. Rule 3 refuses because the token would
*not* end up correct.

**Rules 4 and 5 run before rule 2.** A logon SID is by construction
outside the principal's domain, so an authority that applied membership
scope first would refuse the logon that rule 4 says to survive by
dropping. Duplicates are the same shape of problem. The drops are about
what a source should never have sent; the scope tests are about what it
is permitted to claim, and only what survives the first is subject to
the second.

An authority MUST also place `primary_group` on the token even when the
source did not list it among `groups`, since a token's primary group
must be a group the token carries.

## 2.13.2 Refusal

`msg_type` = `0x8004`. Source to authority.

| 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.

> [!NOTE]
> A principal requiring no credential is a separate matter, and cannot
> be hidden: a conversation that reaches `Assertion` with no round at
> all says that the named principal exists and needs nothing. That is
> inherent in permitting a zero-round logon rather than a defect in this
> rule, and it is the reason a passwordless principal is a
> configuration decision rather than a convenience.

An authority MAY relay `reason` to the client and MAY replace it. It
MUST NOT relay a reason that reveals a distinction the source was
required not to make.

---

# 2.14 Abandon

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> Telling a source a conversation will not continue, and why the protocol would leak state without it.

`msg_type` = `0x0004`. Authority to source. No fields beyond the header.

Tells a source that a conversation will not continue: the client hung
up, a limit was reached, or the authority terminated the logon for
reasons of its own.

## 2.14.1 Why it exists

Without it, a client that disconnects mid-prompt leaves the source
holding conversation state forever. A source cannot detect this for
itself — it is not party to the client's connection — so the authority
has to say.

## 2.14.2 Rules

1. An authority MUST send `Abandon` for any conversation it opened that
   will not reach a terminal state, unless the connection itself is
   being torn down.
2. A source MUST discard all state for the conversation on receipt, and
   MUST NOT reply.
3. `Abandon` is not a terminal message *from* the source, and no
   `Assertion` or `Refusal` follows it. If one arrives anyway, the
   authority MUST ignore it — the conversation is gone, and a late
   answer to an abandoned question is at best stale.
4. A source that receives `Abandon` for a conversation it does not know
   MUST ignore it. It has already cleaned up, which is the outcome the
   message wanted.

> [!NOTE]
> The natural implementation is to send `Abandon` from whatever owns the
> conversation's lifetime, on any path that does not reach a terminal
> state — including error paths. An authority that sends it only on the
> tidy path will leak conversations exactly when things are already
> going wrong.

---

# 2.15 Query

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> Asking a source about a principal outside a logon — batching, relative identifiers, and the outcomes a query can return.

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.

> [!NOTE]
> This is also why the authority is the only party that *can* answer a
> `getpwuid`. See PGSS §2.13.

### 2.15.1.2 Batching

`keys` is an array so that an authority may ask several questions in one
exchange. An authority MAY send a single key, and one that always does
is conforming.

The array is here from the outset because adding it later would break
every source written against a single-key message. A source with a cheap
local store gains little; a source backed by a remote directory gains
the difference between one query and a hundred.

A source MUST answer every key it is sent, in order, and MUST NOT
reorder, merge or omit results. A source that cannot serve a whole batch
MUST refuse the conversation rather than answer part of it.

## 2.15.2 QueryResult

`msg_type` = `0x8005`. Source to authority. Terminal.

| 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).

> [!NOTE]
> `Refused` is how a source declines to expose a principal it holds —
> which §2.21 has always permitted. It is distinct from `NotFound`
> because the authority may consult another source on `NotFound`, and
> must not on `Refused`: the object was found, and the answer was no.

A `Refused` result is about the object, not about the caller, and an
authority MUST NOT relay it outward as PGSS Logon's `Refused` outcome,
which is reserved for a caller that may not make the request (PGSS
§2.18). What a source declining to expose an object means to a client is
the authority's to decide, and it is not "you lack permission".

A source that declared no `QUERIES` is a third case again. It has not
answered and cannot be asked, so an authority MUST NOT record it as
having answered `NotFound`: a source that was never consulted is not
evidence that an object does not exist, and PGSS §2.18 forbids reporting
`NotFound` on the strength of one. It contributes nothing to the search,
and §2.8 says what an authority should do about the configuration that
produced it.

## 2.15.3 Scope

An authority MUST apply identity confinement (§2.18), membership scope
(§2.19) and numeric scope (§2.20) to a `QueryResult` exactly as to an
`Assertion`, and MUST validate every SID in one structurally before
using it — including the SIDs of references inside a `PRIMARY_GROUP`,
`GROUPS` or `MEMBERS` value, not only the `sid` of the result itself.

"Exactly as to an `Assertion`" is meant literally, and it is the
sentence an implementation is most likely to satisfy by halves. An
authority that confines the object a result names, while relaying the
group references beside it unchecked, has left the whole of membership
scope unenforced on this channel — and a source with no permission to
assert a foreign membership can then report one through a name lookup
that it could not report through a logon.

A query is not a weaker channel than a logon. A source that could name a
principal outside its domain here would be able to make `ls -l` display
another source's principals as its own — and, worse, could then be
believed the next time something compared that name to a SID.

---

# 2.16 Enumeration

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> Paging through a source's principals or a large group's members, with cursors that belong to the source.

`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.

> [!NOTE]
> Restarting would turn a store edit during a `getent passwd` into an
> unbounded loop over a source that never finishes. Reporting an
> incomplete enumeration is the honest outcome, and PGSS §2.17 requires
> the authority to say so.

A source MUST NOT assume a cursor comes back on the same conversation,
or on the same connection, and MUST NOT hold per-cursor state it is
unwilling to discard.

The authority's own cursor, the one it hands its client, is its to
construct — PGSS §2.17 requires it to reject one it did not issue, which
it can only do for a cursor it made. A source's cursor travels inside
it, not as it.

## 2.16.4 Why members are here and not only in Query

`MEMBERS` is a field of `Query` (§2.15), so the common case — a small
group, whose members fit alongside the rest of the record — costs one
exchange.

A group whose membership will not fit is reported through PGSS Logon's
`TooLarge` (PGSS §2.16), and this is where the caller is sent. Paging a
membership through the mechanism that already pages is cheaper than a
third message, and considerably cheaper than the alternative of a
partial member list, which is a wrong answer rather than a smaller one.

## 2.16.5 Enumeration is not existence

An authority MUST NOT use enumeration to determine whether a principal
exists, and MUST NOT infer from a source declining to enumerate that the
source holds nothing.

A directory-backed source able to answer any single question while quite
unable to answer all of them is the expected case, not a degraded one.

---

# 2.17 Change Notification

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> The unsolicited message that lets an authority cache name lookups safely, and what it deliberately does not carry.

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.

> [!NOTE]
> The alternative default — cache anything not explicitly forbidden —
> would make a source written against an older revision silently serve
> stale identity, which is the one class of staleness that decides
> access.

An authority that does not cache at all satisfies this section
trivially, and is conforming. The obligations here bind what an
authority may hold, not whether it must hold anything.

## 2.17.4 What this does not carry

`Changed` says that something changed. It does not say what it changed
to.

Carrying the new value would make this a second, unsolicited path by
which a source could assert identity — one arriving outside any
conversation, with no key to check it against, and no logon in progress
to refuse. An authority that believed it would have accepted an identity
assertion it never asked for.

The authority discards what it holds and asks again through `Query`,
where every scope rule in §2.18 to §2.20 applies.

---

# 2.18 Identity Confinement

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> An assertion outside the source's registered domain is refused and the conversation terminated. No configuration lifts this.

**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 one legitimate exception to confinement — the default, the exception, and why it never extends to identity.

Group membership is the question with a legitimate exception, and it
needs one.

## 2.19.1 The default

By default, an authority MUST refuse an `Assertion` carrying a group
outside the domain of the principal being asserted.

A directory vouches for its own users and its own groups, and nothing
else. Without this rule, a directory-backed source could declare its
users members of `BUILTIN\Administrators` — making a remote authority
the arbiter of who administers this machine.

The test is *relative*: are the group and the principal in the same
domain? It needs no configuration and no knowledge of which domain
belongs to whom, which is why membership scope is enforceable before
anything else about scope is settled.

## 2.19.2 The exception

An authority SHOULD allow a source to be configured as permitted to
assert **memberships** outside the asserted principal's domain.

A local source needs it. Local group membership of *any* principal is a
local decision: "`CORP\Domain Admins` is in `BUILTIN\Administrators`" is
a record this machine keeps, not something a domain controller asserts
at it. Without the exception, a local source could not express the one
thing it is most authoritative about.

An authority MAY grant this to more than one source. Nothing about it is
exclusive.

## 2.19.3 Memberships only, never identity

A source holding this permission **remains fully confined on identity**
(§2.18).

The two are separate because the risks are not symmetric. A source
asserting a foreign *membership* is making a claim about what a
principal may do **on this machine**, which is a local matter and is the
local source's business. A source asserting a foreign *identity* is
claiming to be the authority for somebody else's principal, which is
never anyone's business but that authority's.

> [!NOTE]
> Specifying the permission narrowly — memberships only — from the
> outset is what allowed identity confinement to be switched on later
> without changing what the setting means. A permission defined as "this
> source is trusted" would have had to be redefined, and every existing
> configuration reinterpreted, the day identity scope arrived.

## 2.19.4 The primary group is a membership

`primary_group` (§2.13) is subject to this section exactly as a listed
group is, and an authority MUST apply the test to it.

It would otherwise be a way round: the authority is required to place
the primary group on the token whether or not the source listed it, so a
source that named `BUILTIN\Administrators` there and nowhere else would
obtain a membership the group array denies it.

An authority that adds an unlisted `primary_group` to the membership set
MUST do so **before** applying this section, not after. Adding it
afterwards reintroduces exactly the route the rule closes.

This binds a primary group the **source asserted**. A default the
authority substituted for an empty field is not the source's claim and
MUST NOT be tested against the source's scope (§2.13); an authority that
tested its own default would refuse every logon from a source that
simply left the field empty.

## 2.19.5 Claims are the same question, unanswered

A claim (§2.13) is a trusted input to conditional ACE evaluation, so
asserting one can produce a grant just as asserting a membership can.
*Which claim names a source may assert* is therefore the same shape of
control as this section — and it is **not yet specified**.

The gap is stated rather than papered over. An authority federating to a
source it does not fully trust should consider claims as it considers
foreign memberships, and a future revision is expected to define the
control here.

> [!NOTE]
> Active Directory splits this in a way worth borrowing: claim *type
> definitions* are forest-level configuration while claim *values* are
> per-user attributes. The equivalent split — the machine decides which
> claims exist, the source supplies values for them — is the obvious
> shape for the control, and is why this is a gap in configuration
> rather than a flaw in the message format.

---

# 2.20 Numeric Scope

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> Every unix_id a source asserts is rebased into the range it was granted, refused rather than clamped when out of range.

**A source's POSIX identifiers are relative. The authority assigns the
range and applies the base; a source is told its range but MUST NOT act
on it.**

Every `unix_id` in an `Assertion` — the principal's and each group's —
and every identifier in a `QueryResult` is an offset within a range the
authority assigned that source. The authority adds the base before the
number reaches a token or a caller.

This is the numeric counterpart of identity confinement (§2.18). That
section stops a source naming principals outside its namespace; this one
stops it *numbering* them outside its namespace.

## 2.20.1 The range

An authority MUST assign each source a range, as a **base** and a
**count**, spanning `[base, base + count)`.

> [!NOTE]
> Mainline configures both per source in the registry, as `UnixIDBase`
> and `UnixIDCount` alongside the domain pin (§2.10). How the assignment
> is expressed is the authority's own design; what it must satisfy is
> this section.

An authority MUST reserve a band below every source's base for
identifiers of its own — well-known SIDs, service SIDs, confinement SIDs
— none of which come from any directory. The band has to be generous,
because the last of those categories has no bound.

## 2.20.2 Rebasing

Given a relative identifier `r` from a source with range
`(base, count)`, the authority computes `base + r`, and MUST refuse to
produce a number at all when:

- `r` is **0**. Zero is not an identifier; it is how a source says it
  has no number for something. It MUST NOT become `base`.
- `r` is **at or past `count`**. The source has reached outside the
  range it was given.

A number the authority declines to produce projects as *unmapped*, which
the Peios Kernel TRM §3.10.1 defines.

## 2.20.3 Refuse, never clamp

An out-of-range identifier MUST be refused. It MUST NOT be clamped to
the top of the range, and it MUST NOT be reduced modulo the count.

Both alternatives look like graceful degradation and are worse than a
refusal:

- **Clamping** puts two principals on one number, so a filesystem cannot
  tell them apart.
- **Wrapping** lands inside somebody else's range, so one source's
  principal projects as another source's.

The range is a boundary, not an offset, and the count is what makes it
one.

## 2.20.4 Two numbers a source can never reach

Because the base sits above the reserved band and a relative identifier
cannot escape the count:

1. **uid 0.** It belongs to the authority's own table and is not
   reachable by adding a base to anything a source can send.
2. **Another source's numbers.** Whatever a source asserts, arithmetic
   confines it to its own range.

Neither depends on the source behaving. They hold because of what the
source is *able to express*.

## 2.20.5 Well-known SIDs are not the source's to number

An authority MUST use its own identifier for any SID in its reserved
band, and MUST ignore whatever `unix_id` a source sent alongside it.

A source asserting `BUILTIN\Administrators` is stating a membership. It
is not claiming authority over what that group projects to, and
honouring a relative identifier there would place a well-known group
*inside that source's range* — where a second source could number
something else identically.

## 2.20.6 The authority tells the source its range

A source is told its base and count at registration (§2.8). This is
informational and exists so an administration tool can show an operator
the identifier a principal will really project to, rather than the
relative number on disk.

**A source MUST NOT apply the base to what it asserts.** It is told the
range so it can explain itself, not so it can do the arithmetic. A
source that applied its own base would have it applied twice.

> [!NOTE]
> Keeping the base out of the source's stored records is what makes
> rebasing a configuration change rather than a data migration. An
> administrator moving a source's range edits two configuration values;
> nothing the source persists has to be rewritten, because nothing it
> persists was ever absolute.

Disclosure costs nothing that matters. What confines a source is not
ignorance of the base but the authority's refusal to accept a relative
identifier at or past the count — a check the authority performs on
every number it rebases, whatever the source knows.

## 2.20.7 Uniqueness within a source

SIDs are one namespace; POSIX user and group identifiers are two. A
source MUST therefore allocate from a **single counter across every kind
of object it holds**, so that a number issued to a principal is never
issued again to a group.

An authority cannot check this — it sees one assertion at a time — so it
is stated as an obligation on the source (§2.21) rather than as
something enforced.

> [!NOTE]
> The simplest way to satisfy it is to make an object's identifier its
> relative identifier within the domain, which is already unique across
> principals and groups. That has the incidental benefit that one object
> has one number rather than two unrelated ones.

---

# 2.21 Conformance

_Peios / Advanced Peios / PSPU / Principal Source Interface_

> Every requirement of this chapter collected by role, for an authority federating over PSI and for a source serving it.

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

1. Listen; never dial out to a source (§2.3).
2. Never rely on the socket's descriptor as the access control, and
   bound unregistered connections and registration time independently of
   it (§2.6).
3. Bound registered sources, and conversations per source (§2.6).
4. Tear down the connection on a framing error or a failed write, rather
   than attempting resynchronisation (§2.6).

### 2.21.1.2 Registration

5. Require `Register` on conversation `0` as the first message (§2.8).
6. 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).
7. Accept only configured sources, treating an empty configuration as
   *no source may register* (§2.9).
8. Refuse a source that declares no domain (§2.10).
9. Refuse a domain that is not a well-formed locally-issued domain SID
   (§2.10).
10. Refuse a domain another registered source claims (§2.10).
11. Refuse a source declaring a different domain from the one it
    declared before (§2.10).
12. Refuse a source whose declared domain contradicts a configured pin,
    and treat an unparseable pin as refusing rather than as absent
    (§2.10).
13. Send `Registered` only once the source is routable (§2.8).
14. 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

15. Allocate conversation identifiers, never accepting one from a
    source, never opening one with an identifier already live, and
    reserve `0` for registration (§2.7).
16. Route each logon to exactly one source, resolved before any
    credential is collected (§2.11).
17. Never fall back to another source when the owning source is
    unreachable (§2.11).
18. Relay the verified `originator`, taken from the client's socket
    (§2.11).
19. Refuse to relay a prompt for a credential type the client did not
    advertise (§2.12).
20. Enforce its own round and time limits on the relayed exchange
    (§2.12).
21. Send `Abandon` for any conversation that will not reach a terminal
    state (§2.14).

### 2.21.1.4 Assertions

22. Validate every SID structurally before treating it as identity,
    including SIDs carried inside claim values (§2.13).
23. Drop an asserted logon SID, and drop duplicates first-mention-wins,
    **before** applying any scope test (§2.13).
24. Enforce identity confinement, with no configuration lifting it, on
    an `Assertion`, on a `QueryResult`, and on the `sid` of a `Changed`
    (§2.18).
25. Enforce membership scope, subject only to a per-source permission
    covering memberships alone, and apply it to a **source-asserted**
    `primary_group` as well as to listed groups — promoting an unlisted
    one into the membership set before the test (§2.19, §2.13).
26. Never apply membership scope to a `primary_group` it substituted
    itself for an empty field (§2.13, §2.19).
27. Fail the logon on a malformed group SID, an unusable claim, a
    `canonical_name` PGSS §2.15 forbids, or an out-of-range identifier
    (§2.13).
28. Place `primary_group` on the token even when the source did not list
    it among `groups` (§2.13).
29. Perform derivation itself, and never accept privileges, integrity,
    or a token from a source (§2.4).
30. Never relay a refusal reason revealing a distinction the source was
    required not to make (§2.13).

### 2.21.1.5 Identifiers

31. Apply the source's base to every relative identifier it accepts, and
    never accept one already rebased (§2.20).
32. Refuse a relative identifier of `0` or one at or past the source's
    count, rather than clamping or wrapping it (§2.20).
33. 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

34. 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).
35. Never send more keys in one `Query` than the source's `max_batch`,
    nor more than 64 whatever it declared (§2.8).
36. Resolve an absolute POSIX identifier to a source and a relative
    identifier itself, and never send an absolute one to a source
    (§2.15).
37. Apply identity confinement, membership scope and numeric scope to a
    `QueryResult` exactly as to an `Assertion`, and validate every SID
    in one structurally — including those of references inside a value
    (§2.15).
38. Rebase every identifier in a result, under the rules of §2.20
    (§2.15).
39. Qualify a source's `canonical_name` itself; never require a source
    to (§2.15).
40. Consult no further source on a `Refused` result, treat only
    `NotFound` as leave to continue, and never relay a source's
    `Refused` outward as PGSS Logon's `Refused` outcome (§2.15).
41. Never record a source it may not ask — one that declared no
    `QUERIES` — as having answered `NotFound` (§2.15).
42. Relay cursors opaquely, never construct or modify a source's, and
    never restart an enumeration on a source's behalf (§2.16).
43. 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).
44. Never infer from a source declining to enumerate that it holds
    nothing (§2.16).

### 2.21.1.7 Caching

45. Not cache a source's answers at all unless it declared
    `PUSHES_CHANGES` or a non-zero `entry_ttl` (§2.8, §2.17).
46. Not hold an answer beyond a declared `entry_ttl` (§2.8).
47. Accept `Changed` at any time after `Registered`, and never reply to
    it (§2.17).
48. Re-read through `Query` after an invalidation, and never take a new
    value from `Changed` (§2.17).
49. Treat the loss of a source's connection as `All` for that source
    (§2.17).

An authority that holds nothing satisfies 45 to 49 trivially.

## 2.21.2 Source obligations

A principal source MUST satisfy all of the following.

### 2.21.2.1 Connection

1. Connect to the authority; never listen for it (§2.3).
2. Open with `Register` on conversation `0`, carrying its name and its
   domain (§2.8).
3. Send nothing else before receiving `Registered` (§2.8).
4. Report itself ready — to an init system or equivalent — only after
   `Registered` (§2.3).
5. Declare the same domain on every registration, for the life of the
   machine's configuration (§2.10).
6. Tear down the connection on a framing error or a failed write, on
   every path including an unsolicited `Changed` (§2.6, §2.17).

### 2.21.2.2 Conversations

7. Reply on the conversation identifier it was given, and never invent
   one (§2.7).
8. 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).
9. Refuse an `Authenticate`, `Query` or `EnumerateSource` arriving on
   conversation `0` (§2.7).
10. Bound the conversations it tracks itself, rather than relying on the
    authority's limit (§2.6).
11. Refuse beyond that bound with `AuthorityUnavailable`, rather than
    dropping silently (§2.11).
12. Discard conversation state on `Abandon`, and not reply (§2.14).

### 2.21.2.3 Answering

13. Send exactly one terminal message — `Assertion` or `Refusal` — per
    conversation (§2.13).
14. Assert only principals within its declared domain (§2.18).
15. Assert group SIDs and identifiers only, never attributes (§2.13).
16. 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.
17. Never distinguish an unknown principal from a bad credential — by
    denial code, by reason, or by timing (§2.13).

### 2.21.2.4 Identifiers

18. Assert **relative** identifiers only, and never apply its own base
    (§2.20).
19. Allocate from a single counter across every kind of object it holds,
    so that no number is issued twice (§2.20).
20. Send `0` for any object it does not number, including every group it
    does not own (§2.20).
21. 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.

22. Declare only capabilities it implements, and answer every message
    type it declared (§2.8).
23. Answer every key of a `Query`, in order, without reordering, merging
    or omitting — or refuse the conversation whole (§2.15).
24. Send `Found`, `NotFound` or `Refused` in a result, and never
    `Unavailable` or `Malformed` (§2.15).
25. Return its own canonical spelling of a name, unqualified (§2.15).
26. Return **relative** identifiers in a result, exactly as in an
    `Assertion` (§2.15, §2.20).
27. Answer only for principals within its declared domain, on a query as
    on a logon (§2.18).
28. Answer `Refused`, never an empty `Found`, wherever it is declining
    rather than reporting an absence — including a membership it will
    not expose, a key naming an object it does not hold, and a key of
    the wrong kind (§2.16).
29. Refuse a cursor it can no longer honour, rather than restarting or
    answering from a changed store (§2.16).
30. Hold no per-cursor state it is unwilling to discard unasked (§2.16).
31. 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).
32. Send `Changed` before the altered answer becomes observable, if it
    declared `PUSHES_CHANGES` (§2.17).
33. Declare a non-zero `entry_ttl` if it does not push changes and can
    tolerate its answers being held, and SHOULD declare one even if it
    does (§2.8, §2.17).

### 2.21.2.6 Credentials

34. Store verifiers that are **not** usable as credential material —
    nothing a challenge could be recomputed from (PGSS §2.11).
35. Erase credential material, and every buffer it was decoded through,
    before that memory is released (PGSS §2.12).
36. 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_

> Every PSI message by number and direction, the protocol constants, and the field limits — including the ceiling that actually binds a page.

## 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_

> The values PSI defines for itself — key types, result outcomes, change scopes, capabilities, and claim value types and flags.

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_

> A checklist of exactly what PSI shares with PGSS Logon unchanged, what it adds, and what it changes.

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_

> The approaches PSI exists to avoid — in-process authentication packages above all — and the ones it adopts.

## 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_

> The three channels by which programs deposit logs and metrics with an observability service, and the roles at each end.

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.

> [!NOTE]
> The Query Interface reaches *events* as well as logs and metrics, even
> though events do not arrive over any interface here. That asymmetry is
> deliberate: PSPK's ring-buffer interface is the raw transport, requires
> SeSecurityPrivilege, and applies no per-record access control. The
> query interface is how everything else reads events, and it is the only
> way to read them after the ring buffer has moved on.

---

# 3.2 Terminology

_Peios / Advanced Peios / PSPU / Observability Interfaces_

> Terms this chapter borrows unchanged from PSPK's KMES event stream and from elsewhere in the corpus.

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_

> Two datagram sockets carrying data inward and one stream socket carrying it out — why the split, and how each is protected.

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 decision that shapes both ingestion channels — a producer is never stalled, so data may be lost — and what a collector must not do about it.

The single decision that shapes both ingestion interfaces is this:
**a producer is never slowed down, and never told that a record was
lost.**

## 3.4.1 The obligation

A collector MUST NOT exert backpressure on a producer. A producer MUST
NOT stall, block, retry or otherwise change its behaviour because a
collector is slow, busy, or absent.

The consequence is accepted openly. When a collector cannot drain an
ingestion socket as fast as producers fill it, the kernel discards
datagrams. Neither party is notified. A collector MUST NOT report the
loss to the producer, because there is no reply message on a datagram
channel and adding one would reintroduce the coupling this rule exists
to prevent.

## 3.4.2 Why loss is acceptable here

A lost log line is an inconvenience. A lost metric sample is a visible
gap in a chart. Neither is a failure of the system, and neither is worth
the cost of the alternative — which is either blocking the producer or
buffering without bound, and the second is only the first with a delay.

Events are the counter-example, and the reason the boundary between
events and logs matters. An event may be a security audit record whose
absence is itself the finding, so events do not travel on either
interface in this chapter: they travel through KMES, where loss is
detected, bounded and recorded. A program with data that must not be
lost emits an event; a program with output for a human to read writes a
log.

> [!NOTE]
> This is the practical test for producers deciding where output belongs.
> If you would want to know that a record went missing, it is not a log
> and not a metric.

## 3.4.3 What a collector must not do about it

A collector MUST NOT emit an event, write a log entry, or perform any
other work proportional to the volume of malformed or unwanted input it
receives.

Ingestion input is 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_

> MessagePack on every interface, the canonical subset producers must emit, and how timestamps are carried.

## 3.5.1 MessagePack

Every structured value on all three interfaces — log records, metric
samples, query requests, query responses — is encoded as MessagePack.
Strings are UTF-8.

The choice is inherited rather than made here: KMES event payloads are a
single MessagePack value, so a collector already carries a decoder and a
query result can carry an event payload outward without re-encoding it.

A decoder MUST accept any valid MessagePack encoding of a value it is
given. In particular a producer MAY use any length-prefix width that can
represent the value, and a collector MUST NOT require the shortest.

## 3.5.2 Canonical MessagePack

Where this chapter requires a **canonical** encoding, the value MUST be
encoded as follows:

- Nil and booleans use the fixed singleton encodings.
- Integers use the shortest encoding that preserves signedness:
  non-negative values use positive fixint, `uint8`, `uint16`, `uint32`
  or `uint64`; negative values use negative fixint, `int8`, `int16`,
  `int32` or `int64`.
- Floats are encoded as `float64`. Finite values use their IEEE-754
  binary64 representation; the infinities use the normal binary64
  encodings; **any** NaN is encoded as the single quiet NaN bit pattern
  `0x7ff8000000000000`.
- Strings, binary values, arrays and maps use the shortest length-prefix
  form capable of representing the length.
- Arrays encode each element recursively, in order.
- Maps encode keys and values recursively, with entries sorted by the
  canonical encoded key bytes; ties are broken by the canonical encoded
  value bytes.

Canonical encoding exists so that two values that are equal are also
byte-identical, which is what makes them comparable and orderable
without decoding. It is required in exactly two places: histogram
sample storage, where it makes a sample map a stable value (§3.11), and
array comparison in query ordering and grouping (§3.21).

It does **not** constrain what a producer sends. Ingestion accepts any
valid encoding.

## 3.5.3 Timestamps

A timestamp is wall-clock time in **nanoseconds since the Unix epoch,
UTC**, as a signed 64-bit value.

The **timestamp domain** is `0` to `9223372036854775807` inclusive. A
value outside it is invalid wherever it appears: as a producer-supplied
timestamp (§3.8, §3.12), as a query time literal (§3.19), or as a value
in a result record.

The domain has no negative half. A collector MUST reject a negative
timestamp rather than storing a time before 1970, and a query whose time
arithmetic lands below zero — `SINCE 100000d ago`, for example — MUST
produce an error rather than clamping.

> [!NOTE]
> The upper bound is `i64::MAX` nanoseconds, which is the year 2262. The
> domain is stated as a closed range rather than "whatever fits" because
> both parties must agree on where the edge is: a producer that clamps
> and a collector that rejects would disagree about the same sample.

Wall-clock time is not monotonic. A collector MUST store the timestamp
it is given or derives without correcting it, and MUST NOT assume that
timestamps within one time series increase (§3.13). A clock step
backwards produces records that are out of order with respect to their
arrival, and every ordering rule in this chapter is defined to remain
total and deterministic when that happens.

---

# 3.6 The Log Channel

_Peios / Advanced Peios / PSPU / Observability Interfaces_

> The log ingestion socket — its datagram ceiling, the receive queue as the only buffer, and what reachability a producer may assume.

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).

> [!NOTE]
> §3.A gives the mainline value and adjustable range
> for this bound and every other in this chapter.

**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_

> The MessagePack map that carries a log record, one or batched — origin, is_error, timestamp and job_id.

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:

```text
[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_

> The three scopes of silent failure — the whole datagram, one record, or one field — and what a collector adds on the way in.

Every failure on this channel is silent (§3.4). What differs between
failures is *how much* is lost: the whole datagram, one record, or only
one field.

## 3.8.1 The three scopes of failure

**The datagram is discarded** when it cannot be resolved into records at
all:

- it is not valid MessagePack
- it decodes to something that is neither a map nor an array of maps
- the kernel reported it truncated (§3.6)

**One record is discarded**, and the others in the same datagram are
still processed, when the record itself is unusable:

- a required field is absent
- a required field has the wrong type — `origin` an integer, say
- `origin` is the empty string
- the map contains a duplicate top-level key

**One field is ignored**, and the record is still stored, when an
optional field is unusable:

- `timestamp` is not an integer, is negative, or is outside the
  timestamp domain (§3.5)
- `job_id` is not binary, or is binary of a length other than 16

A collector MUST implement all three scopes as stated. In particular it
MUST NOT discard a record because an optional field was malformed: a
producer with a broken clock or a mangled correlation key still has a
log line worth keeping, and the field it got wrong is the field of least
value in the record.

## 3.8.2 Duplicate keys

A record map carrying the same top-level key twice MUST be discarded.

A collector MUST NOT resolve the duplicate by taking the first or the
last. MessagePack decoders differ on which they keep, and the fields
here are entirely producer-controlled, so a rule that depended on
decoder behaviour would let a producer choose which of two `origin`
values a given collector saw. Discarding is the only answer that is the
same everywhere.

## 3.8.3 Batches

A batch is validated **per record**. A malformed record in a batch MUST
NOT cost the valid records beside it.

A producer SHOULD batch under sustained load. Batching amortises the
syscall over many records, and the ceiling (§3.6) is per datagram, so a
batch is also the only way to use the channel's capacity efficiently.

The encoded datagram, batched or not, MUST NOT exceed the ceiling. A
producer that batches without bounding the encoded size will eventually
build a datagram that is discarded whole — which is the one case where
batching loses more than sending singly would have.

## 3.8.4 What a collector adds

A collector supplies the boot ID (§3.2) and, when the record omitted
`timestamp`, its own clock reading at receipt. It MUST NOT alter any
other field, and MUST store `message` byte-for-byte as given.

A collector MUST NOT parse `message`. If the text happens to be JSON, or
logfmt, or anything else structured, that is the producer's business:
this interface carries lines, and a producer with structured data to
record emits an event instead (§3.4).

---

# 3.9 The Metric Channel

_Peios / Advanced Peios / PSPU / Observability Interfaces_

> The metric ingestion socket, separate from the log socket, and why the collector is a sink rather than a scraper.

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).

> [!NOTE]
> §3.A gives the mainline value and adjustable range
> for this bound and every other in this chapter.

## 3.9.1 A sink, not a collector of its own

The collector is **pushed** to. It MUST NOT scrape an endpoint, read a
kernel interface, or poll anything to obtain metrics; every sample it
holds arrived on this socket because a producer sent it.

What gathers the measurements is a separate concern and a separate
program. A collection agent that reads system counters and submits them
is an ordinary producer here, with no privileged position and no
interface of its own.

> [!NOTE]
> The push direction follows from the loss model rather than from taste.
> A pulling collector must reach every producer on a schedule, which
> makes it responsible for their availability and makes a slow producer
> the collector's problem. Pushing keeps each producer responsible for
> its own submissions and keeps a slow or dead producer invisible to
> everything except the gap it leaves.

## 3.9.2 Batching

Batching matters more here than it does for logs. 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_

> What a metric is on this interface — names, labels, and the counter, gauge and histogram types, all valued as binary64.

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:

```text
[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:

```text
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).

> [!NOTE]
> Label keys are constrained to the identifier grammar for the reason
> that matters most in practice: a key that cannot be written in a query
> cannot be filtered on, grouped by, or granted access to. A label you
> cannot address is storage you cannot reach.

**Label cardinality is the producer's responsibility.** Each distinct
combination of label values is a distinct series, so labels whose values
are unbounded — request identifiers, user-supplied strings, timestamps —
produce series without limit, and a collector is required neither to cap
them nor to degrade gracefully when they arrive. A producer SHOULD use
labels whose value sets are small and known. A dimension that is not
bounded belongs in an event payload, where it costs one field, not in a
label, where it costs a series.

## 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.

> [!NOTE]
> Observations above the highest boundary are counted but not located.
> A reader asking for a high percentile of a distribution whose tail
> overflows gets no answer rather than a wrong one (§3.25), so a producer
> SHOULD choose a highest boundary above the values it expects to see.

## 3.10.4 Values are floating point

Numeric input MAY be a MessagePack integer or a MessagePack float; both
are converted to binary64 with round-to-nearest, ties-to-even. Every
value a collector stores and every 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_

> The MessagePack map that carries one metric sample, the histogram value shape, and why type is per record rather than per series.

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_

> What causes a metric datagram or a single record to be discarded, silently, and why the timestamp rule differs from the log channel's.

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
- `type` is not exactly `"counter"`, `"gauge"` or `"histogram"`

**Name and labels** (§3.10)

- `name` is empty or does not match the identifier grammar
- a label key or value is not a string, or is empty
- a label key does not match the identifier grammar
- a label value contains `=` or `,`
- a label key is repeated within the record
- a label key is one of `timestamp`, `boot_id`, `name`, `type`, `value`

**Timestamp**

- `timestamp` is present and is not an integer, is negative, or is
  outside the timestamp domain (§3.5)

**Counter and gauge values**

- the value is not a number
- it converts to a non-finite binary64
- it is negative and the type is counter

**Histogram values**

- the value is not a map, or its map has a duplicate key
- a field is absent or has the wrong type
- `boundaries` is empty
- a boundary or `sum` converts to a non-finite binary64
- `counts` and `boundaries` differ in length
- the converted boundaries are not strictly increasing
- a count is negative, the counts are not non-decreasing, or a count
  exceeds `total_count`
- `total_count` is zero and any count or `sum` is non-zero

**Series consistency**

- the record resolves to an existing series whose type differs (§3.10)

## 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_

> What makes two samples the same time series — name, labels and histogram boundaries — and what pointedly does not participate.

Two samples belong to the same time series when they agree on:

- the **name**, compared exactly, byte for byte; and
- the **label set**, compared as an unordered set of key-value pairs,
  each compared exactly; and
- for histograms, the **bucket boundaries**, compared as an ordered
  sequence of binary64 values.

Nothing else participates. Type does not: a record whose type disagrees
resolves to the series and is then discarded for disagreeing (§3.10).
Boot ID does not: a series continues across a reboot, and a client that
wants one boot's worth filters for it (§3.25). Time does not.

## 3.13.1 Order does not distinguish a label set

`{core: "0", host: "a"}` and `{host: "a", core: "0"}` are the same
series. A collector MUST compare label sets as sets.

To do so it needs a canonical form, and the form in use is the reason
label values may not contain `=` or `,` (§3.10): pairs are sorted by key
in unsigned UTF-8 byte order, each written `key=value`, and joined with
commas. Because neither delimiter can occur inside a key or a value, no
escaping is needed and no two distinct label sets can produce the same
string.

The byte form itself is the collector's business and this chapter does
not require it. What it requires is the property: **a label set has
exactly one identity, independent of the order the producer wrote it
in.** The delimiter reservation is stated normatively because it binds
the *producer*, and a producer cannot see the encoding that motivates it.

## 3.13.2 Absent labels and empty labels

A record with no `labels` field, a record with an empty `labels` map,
and a record whose labels were all discarded are the same series: the
one with no labels. There is no distinction between "unlabelled" and
"labelled with nothing".

## 3.13.3 Boundaries are identity, not metadata

Two histogram samples with different boundaries are different series
even when name and labels agree.

This is the consequence that surprises producers, and it is unavoidable:
cumulative counts against one set of bucket edges cannot be compared
with counts against another, so calling them one series would mean
computing percentiles across incommensurable distributions. A producer
that re-tunes its buckets each collection cycle creates a series each
cycle, each holding a single sample and each surviving until retention
removes it.

A collector MUST NOT defend against this. It is a producer defect, it is
indistinguishable at the interface from legitimately introducing a new
metric, and every defence available — capping series, merging near-equal
boundary sets, rejecting a second boundary set for a name — would break
a correct producer to inconvenience an incorrect one.

## 3.13.4 Series are created, never announced

A series comes into existence when its first sample arrives. There is no
registration message, no schema, and no way to declare a series in
advance or to retire one.

A collector MUST NOT require a series to be known before a sample of it
is accepted, and MUST NOT limit how many series exist. A series with no
remaining samples ceases to exist when retention removes the last of
them; nothing else removes one.

> [!NOTE]
> The absence of a cap is a deliberate and load-bearing decision, and it
> is worth being clear about what it costs. Labels are producer-supplied
> and unverified (§3.4), so any process that can reach the metric socket
> can create series without limit, and each persists until the retention
> window elapses. The Security Descriptor on the socket (§3.3) is the
> only thing that bounds this.

## 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_

> The query socket — one query per connection, the identity captured at connect, and why no credentials cross it.

A collector MUST expose an `AF_UNIX` `SOCK_STREAM` socket for queries,
protected by a Security Descriptor as §3.3 requires.

One socket serves all three data types. The mode a query runs in —
events, logs or metrics — is determined by parsing the query string
(§3.18), never by the transport, so a client needs no connection setup,
no mode selection, and no separate endpoint per data type.

## 3.14.1 One query per connection

A connection carries exactly one query. A client that wants two
concurrent queries opens two connections.

A collector MUST close the connection after the terminal message of a
non-streaming query (§3.16), and MUST treat a client disconnect as
cancellation of a streaming one.

> [!NOTE]
> This is the opposite of the identity-lookup channel in PGSS §2.14,
> which multiplexes tagged requests over one connection. The reason is
> the shape of the work rather than a difference in taste: a lookup is
> small, uniform and answered in one message, so correlating many on one
> connection saves real cost. A query may run for thirty seconds, may
> stream indefinitely, and may be cancelled — and every one of those is
> expressed by the connection itself, with no correlation identifier and
> no cancellation message to specify.

## 3.14.2 Identity

A collector MUST establish the client's identity from the connected
socket, before executing anything, by obtaining the peer's token. This
is possible here and not on the ingestion channels, and it is the whole
reason the query channel is a stream (§3.3).

The token is captured **once**, at connection time, and is a snapshot. A
client whose privileges change while a query runs — and in particular
while a streaming query runs, which may be indefinitely — is evaluated
throughout against the token it connected with.

If a collector cannot obtain the peer token, it MUST refuse the query.
It MUST NOT execute a query for an unidentified caller, and MUST NOT
fall back to any other means of identifying one.

## 3.14.3 No credentials cross this channel

There is no message with which a client offers a credential and none
with which a collector asks for one. Identity is established from the
connection and from nothing else.

## 3.14.4 Concurrency

A collector MUST bound the number of queries it will execute at once,
and MUST reject a query beyond the bound with an error rather than
queueing it behind the others.

The streaming bound is the lower of the two and is enforced separately,
because a streaming query holds its resources for as long as its client
stays connected while an ordinary one holds them for at most a timeout
(§3.16).

> [!NOTE]
> §3.A gives the mainline value and adjustable range
> for this bound and every other in this chapter.

Both bounds are global. 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_

> Length-prefixed MessagePack in both directions, the message ceiling, and the requirement that the ceiling admit every record.

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.

> [!NOTE]
> §3.A gives the mainline value and adjustable range
> for this bound and every other in this chapter.

**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.

> [!NOTE]
> The mainline defaults do not satisfy this. The log and metric datagram
> ceilings are 262144 bytes and the query message ceiling is 65536, so a
> producer can deposit a log line four times larger than any response
> that could carry it.

## 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_

> The four response kinds a query can receive, how records are chunked, and how errors and timeouts are reported.

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.

> [!NOTE]
> §3.A gives the mainline value and adjustable range
> for this bound and every other in this chapter.

The clock starts once the request has been decoded and the caller's
token obtained, and it covers everything that follows: parsing, access
checks, cross-type pre-computation, execution, merging, aggregation,
pagination, projection and transmission. A collector MUST send `"end"`,
or for a streaming query `"watch"`, before it expires.

The timeout bounds the **initial result set only**. Once a streaming
query has sent `"watch"` its watch phase is not time-limited; what
bounds it instead is the streaming concurrency limit (§3.14), the
distinct-value limit (§3.27), and the client's own ability to keep up
(§3.27).

On expiry a collector MUST cancel the query and send `"error"`. Any
`"ok"` messages already sent are discarded by the client under the rule
above.

---

# 3.17 Value Encoding

_Peios / Advanced Peios / PSPU / Observability Interfaces_

> How every value in a result record is encoded — including why a GUID is a string — and why missing and null are the same value.

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_

> One string that names a mode, narrows to some data and says what to do with it — the three modes, the clauses, and their execution order.

A query is one string. It names a mode, narrows to some data, and says
what to do with it.

```text
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.

- **`EVENTS`** searches structured event records, primarily by event
  type (§3.23).
- **`LOGS`** searches log output, primarily by origin (§3.24).
- **`METRIC`** evaluates measurements, primarily by name and labels
  (§3.25).

Events and logs are *record-oriented*: collections you search, returning
the records that matched. Metrics are *value-oriented*: measurements you
evaluate, returning numbers computed from samples. The modes differ
because the data differs, and forcing all three through one shape would
serve none of them.

## 3.18.2 The primary selector

Immediately after the mode comes an optional **primary selector**,
specific to the mode: an event type pattern, `FROM` with one or more log
origins, or a metric name with an optional label selector. It narrows
the data before anything else runs.

A primary selector MUST NOT be repeated unless its mode defines a list
form — `LOGS FROM a, b` is one selector naming two origins, not two
selectors.

## 3.18.3 Clauses

Everything after the primary selector is a **clause**, and clauses MAY
appear in any order. `EVENTS SINCE 1h ago TAKE 10` and
`EVENTS TAKE 10 SINCE 1h ago` are the same query.

Order of appearance never affects meaning. Execution follows the fixed
sequence below regardless of how the string was written, so a collector
MUST NOT derive semantics from clause position.

These clauses work identically in all three modes:

| 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:

- **`WHERE` is repeatable.** Multiple `WHERE` clauses are combined with
  `AND`, each treated as a parenthesised group: `WHERE a == 1 OR b == 2`
  followed by `WHERE c == 3` means `(a == 1 OR b == 2) AND c == 3`.
- **`SELECT` is repeatable** where it is valid at all, and is additive:
  `SELECT timestamp SELECT event_type` names both fields.

Both exist so that a query can be built up in pieces — by a tool
appending a filter, or by a person adding one to a query they already
have — without rewriting what is already there.

## 3.18.6 Counts

`TAKE`, `SKIP` and the `N` of `TOP N BY` are unsigned decimal integers
that MUST fit in 64 bits. A negative, hexadecimal, floating-point or
missing count is a parse error.

`SKIP` defaults to 0. `TAKE` omitted means no limit. `TAKE 0` and
`TOP 0 BY` are valid, and return no records after every earlier phase
has run — which is not the same as not running the query, because a
`TOP 0 BY` still counts and a `TAKE 0` still enforces access control.

> [!NOTE]
> A non-aggregating query without `TAKE` has no implicit limit, and a
> broad one over a long range may match millions of records. The timeout
> (§3.16) is the only backstop.

## 3.18.7 Case

Keywords are matched case-insensitively, using ASCII case folding, in
grammar positions where a keyword is expected. This document writes them
in uppercase by convention only.

Identifiers are case-sensitive, except where the language defines a
named alias for a value (§3.23).

A word spelled like a keyword MAY be used where the grammar expects an
identifier or a value: `LOGS FROM stream` selects the origin `stream`,
while `LOGS STREAM` enables streaming. A collector MUST resolve the
ambiguity by grammar position and MUST NOT reserve keywords globally.

---

# 3.19 Lexical Rules and Literals

_Peios / Advanced Peios / PSPU / Observability Interfaces_

> The lexical layer — identifiers, strings, binary, numbers, durations and times — and what fixes the evaluation time.

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:

```text
[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.

> [!NOTE]
> Refusing surrogates rather than pairing them means there is exactly one
> way to write every character, and no way to write a sequence that is
> not valid UTF-8. A language that accepts lone surrogates has to decide
> what they mean when compared against stored text that cannot contain
> them.

## 3.19.3 Binary

A binary literal is a lowercase `x`, a double quote, an even number of
hexadecimal digits, and a closing quote:

```text
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.

```text
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.

> [!NOTE]
> Equality against null is refused rather than defined because every
> definition of it is a trap. Under three-valued logic it is neither
> true nor false, which no other operator here does; under two-valued
> logic it silently disagrees with SQL. `IS NULL` says what was meant
> and cannot be misread.

## 3.19.7 Durations

A duration is an unsigned decimal integer followed immediately by `s`,
`m`, `h` or `d` — seconds, minutes, hours or days. A zero duration is a
parse error.

## 3.19.8 Times

| 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_

> The operators, case-folding on strings, why types never coerce, how absent fields behave, and how predicates combine.

## 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.

```text
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.

> [!NOTE]
> Folding is ASCII-only rather than Unicode because full case folding is
> locale-dependent, version-dependent and expensive, and because the data
> it would be applied to — identifiers, service names, event types — is
> ASCII by construction (§3.19). A collector that folded Turkish dotless
> `ı` would have to fold it the same way as every other collector, for
> every Unicode version, forever.

## 3.20.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_

> SORT, GROUP and DISTINCT — and the two rules that make paging a query safe: one query, one order.

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.

```text
SORT timestamp DESC
SORT origin ASC, timestamp DESC
```

If the named fields do not uniquely order two records, a collector MUST
append internal tiebreakers until the order is total. The tiebreakers
are not query-language fields, are never emitted in a result record, and
a client MUST NOT depend on their identity — only on their effect, which
is that the order is stable.

When no `SORT` is present:

- **Events and logs** are ordered by timestamp descending, most recent
  first — the order a person reading a log wants.
- **Metrics** are ordered by timestamp ascending, the order a chart
  wants.

## 3.21.2 Value ordering

`SORT` uses the query language's ordering, not the storage engine's.
Missing fields and explicit nulls are equivalent. Ascending order sorts
by type first, in this order:

1. Null
2. Boolean, `false` before `true`
3. Numeric, integers and floats compared mathematically (§3.20)
4. String and GUID, ASCII-folded
5. Binary, unsigned lexicographic
6. Array

`DESC` reverses the whole ordering, type order included.

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 map — the event, log and metric field sets, the reserved header names, and how nesting is flattened.

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.

> [!NOTE]
> The header must win because header fields are the ones the kernel
> stamped and an emitter could not influence (PSPK §2). If a payload key
> could shadow `process_guid`, an emitter could choose what its own
> events appeared to come from, and every query filtering on identity
> would be answerable by the party being investigated.

### 3.22.1.2 Flattening

Payload maps are flattened recursively, path segments joined with `.`:
a payload `{source: {name: "x"}}` exposes the field `source.name`.

Each map key on a queryable path MUST be a MessagePack string matching:

```text
[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 mode — the type pattern, origin class aliases, and the aggregations available over events.

```text
EVENTS [type_pattern] [clauses…]
```

## 3.23.1 The type pattern

The primary selector is an optional event type pattern, placed
immediately after `EVENTS`.

```text
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 |

```text
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.

```text
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.

```text
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.

```text
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.

```text
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

- `COUNT` returns an unsigned integer.
- `SUM` over integers returns an integer when the exact mathematical sum
  fits in signed or unsigned 64 bits. If it does not, or if any input
  was a float, it returns a `float64`. If that would be non-finite, the
  query MUST fail with an error rather than returning an infinity.
- `AVG` returns a `float64` whenever at least one numeric value
  contributed.
- `MIN` and `MAX` return the winning value itself, under exact numeric
  comparison. When an integer and a float tie, the integer wins.

## 3.23.4 Ordering

Without `SORT`, results are ordered by timestamp descending, ties broken
as §3.21 requires.

## 3.23.5 INDEX

```text
EVENTS INDEX target_sid
```

`INDEX` asks the collector to prioritise a field for query
acceleration immediately, rather than waiting for it to be observed
often enough to be prioritised automatically. It exists for incident
response, where the field that suddenly matters has never been queried
before.

`INDEX` is an **administrative operation**, not a query. It returns no
records. A collector MUST check the caller's token against a Security
Descriptor governing administration of the collector — one distinct from
the read-path descriptors of §3.28 — and MUST refuse a caller that does
not hold it. A collector without such a descriptor MUST refuse `INDEX`
outright.

A collector MAY treat `INDEX` as advisory and MAY decline the request,
shed the acceleration later, or do nothing at all. It is a hint about
priority; the accelerations a collector maintains are its own business,
and a conforming collector that maintains none accepts `INDEX` and has
nothing to do.

There is no command to undo it, because there is nothing to undo: a
collector reconsiders its own accelerations continuously and the hint
decays with disuse.

> [!NOTE]
> The right to issue `INDEX` MUST NOT be the read right. Accelerating a
> field costs write throughput on every record thereafter, so `INDEX` is
> a way for a caller to degrade the system for everyone, and a caller
> permitted only to read data has not been permitted to do that.

---

# 3.24 Log Queries

_Peios / Advanced Peios / PSPU / Observability Interfaces_

> LOGS mode — its three optional primary selectors, projection and aggregation, and why there are no payload fields.

```text
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.

```text
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.

```text
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).

```text
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`.

```text
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 mode evaluates rather than searches — series selection, homogeneity, transforms, percentiles and scalar aggregations.

```text
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).

```text
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.

```text
METRIC cpu.usage                    -- average across all cores
METRIC cpu.usage MAX                -- maximum across all cores
```

**Empty brackets — break out.** Each series is returned separately.

```text
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.

```text
METRIC cpu.usage[core="0"]
METRIC cpu.usage[core="0", host="srv1"]
METRIC disk.usage[device STARTS_WITH "sd"]
```

Label predicates are comma-separated and combined with `AND`. They use
the operators of §3.20, and within a label selector `=` is accepted as
equality alongside `==`. Label keys are identifiers; values are
identifiers or quoted strings. An absent label resolves to null, so
`[device IS NULL]` selects the series that carry no `device` label.

## 3.25.2 Homogeneity

After the name, the label selector, `WHERE` predicates and access
filtering have been applied, the remaining series MUST be **of one
type**. A collector MUST reject a selection spanning more than one type
at execution time, with an error asking for a narrower name or an
explicit `WHERE type == …`.

A selection resolving to zero series returns no records — a successful
query with an empty result, not an error.

The rule exists because every function below is defined on one type. A
selection mixing counters and gauges has no meaningful rate, and a
selection mixing either with histograms has no meaningful value at all.

## 3.25.3 Function stages

Function keywords execute in fixed stages regardless of where they were
written:

1. **Transform** — `RATE`, `DELTA`, `P50`, `P95` or `P99`. Operates
   within each series independently and produces scalars. At most one
   per query.
2. **Terminal aggregation** — either a **scalar** aggregation (`AVG`,
   `MIN`, `MAX`, `SUM`) or a **window** aggregation (`AVG_OVER`,
   `MIN_OVER`, `MAX_OVER`, `SUM_OVER`). At most one per query;
   specifying both a scalar and a window aggregation is a parse error.

The pipeline 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.

```text
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.

```text
METRIC request.duration P95
METRIC request.duration[origin="loregd"] SINCE 1h ago P99
```

> [!NOTE]
> The overflow rule is why a producer's highest bucket boundary matters
> (§3.10). A `P99` over a histogram where more than one observation in a
> hundred exceeds the top boundary returns *no record*, which a client
> cannot distinguish from no data. The alternatives are worse — reporting
> the top boundary understates the answer, and reporting an infinity
> violates the finiteness rule — but the failure is silent, and an
> operator seeing an empty high percentile beside a populated low one is
> seeing a mis-provisioned histogram.

## 3.25.5 Scalar aggregations

`AVG`, `MIN`, `MAX` and `SUM` reduce scalars to one value. They MUST NOT
be applied to a histogram series directly.

What they aggregate *over* depends on the brackets:

- **Bracketed**, so one result per series: over time, within each series.
- **Unbracketed without `SINCE`**: over the latest transformed value of
  each matching series. The result timestamp is the greatest of the
  contributing timestamps. A series that cannot produce a value — a
  `RATE` with fewer than two samples, say — contributes nothing.
- **Unbracketed with `SINCE`**: valid only when the selector resolves to
  zero or one series. More than one MUST be rejected with an error
  asking for a window aggregation.

```text
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.

```text
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.

```text
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:

```text
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.

```text
{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_

> The only correlation mechanism in the language — narrowing one data type by a condition on another, with a lookback limit and no join.

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.

```text
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:

```text
timestamp >= t - lower
timestamp <  t + upper
```

Equivalently, a matching record at `e` contributes the true range
`[e - lower, e + upper)`. When `W` is odd the extra nanosecond falls on
the upper side, so the width is exactly `W` and never `W ± 1`.

> [!NOTE]
> §3.A gives the mainline width of the existence window and its
> adjustable range, alongside every other bound in this chapter.

## 3.26.4 The lookback limit

A collector MUST bound how far back a cross-type filter may scan.

> [!NOTE]
> §3.A gives the mainline value and adjustable range
> for this bound and every other in this chapter.

If the effective query range exceeds the limit, a collector MUST reject
the cross-type filter with an error saying the range is too large, and
the error SHOULD suggest narrowing it with `SINCE` or `UNTIL`.

**A query with a cross-type filter and no `SINCE` MUST be rejected.** An
unbounded cross-type scan is never permitted, in any mode, at any
configured limit.

The reason is that a cross-type filter reads a second store in full
before the first query begins. Its cost is set by the *referenced*
data's density, which the client did not select and cannot see, so a
query that looks cheap can scan a hundred times more than it returns.

## 3.26.5 Cost

A cross-type filter is efficient when it is selective — narrow true
ranges eliminating most of the primary source — and expensive when it is
broadly true, which is the case where it also eliminates nothing. A
condition that holds across the whole range costs the full scan of both
stores and returns exactly what the query would have returned without
it.

---

# 3.27 Streaming

_Peios / Advanced Peios / PSPU / Observability Interfaces_

> STREAM turns a query into a live watch — what may be streamed, what still applies during the watch phase, and what is rejected.

`STREAM` turns a query into a live tail. It is a flag, may appear
anywhere in the string, and takes no argument.

Streaming is available for **event and log queries only**. A collector
MUST reject `STREAM` in metric mode.

> [!NOTE]
> Metric streaming is absent because metric data is sampled on an
> interval — typically fifteen seconds — and the consumer is a dashboard
> that polls. Streaming individual samples adds a delivery path with a
> latency budget far finer than the data it carries.

## 3.27.1 The shape of a streaming query

1. The collector executes the query normally and sends the initial
   result set as `"ok"` messages.
2. It sends `"watch"` (§3.16). The query is established at this point
   and not before.
3. It stays open. As records are committed, it evaluates them against
   the query and sends those that match.
4. It continues until the client disconnects, an error terminates it, or
   the collector shuts down.

There is no `"end"` message for a streaming query, ever.

## 3.27.2 What may be streamed

Raw record queries and `DISTINCT` queries. A collector MUST reject
`STREAM` combined with `COUNT BY`, `TOP N BY` or `GROUP` as a parse
error — those produce one answer about a set, and a set that is still
growing has no answer yet.

A collector MUST reject `STREAM` combined with `UNTIL`. An upper time
bound and an unbounded live tail are contradictory requests.

`SINCE` is permitted and applies to both phases, resolved against the
evaluation time captured at query start (§3.19).

## 3.27.3 What still applies during the watch phase

Access control, the primary selector, the `SINCE` bound and every
`WHERE` predicate — cross-type conditions included — are evaluated
against each new record.

`SORT`, `TAKE` and `SKIP` apply to the **initial result set only**.
Streamed records are delivered in commit order and a collector MUST NOT
reorder, limit or skip them: there is no total order over records that
have not arrived, and applying `TAKE` to a stream would silently end it.

`SELECT` applies to streamed records as it does to initial ones.

## 3.27.4 DISTINCT streaming

```text
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.

> [!NOTE]
> §3.A gives the mainline value and adjustable range
> for this bound and every other in this chapter.

If initialising the set or inserting a value would exceed the bound, the
collector MUST terminate the query with an error. It MUST NOT evict:
"not seen before" is the entire meaning of the output, and a set that
forgets would re-emit values it had already reported, which is worse
than stopping.

A collector MUST reject `DISTINCT … STREAM` combined with `SORT`, `TAKE`
or `SKIP`, so that the seen set always corresponds to the complete
initial visible set. `SELECT` is already invalid with `DISTINCT`
(§3.22).

## 3.27.5 Cross-type conditions during the watch phase

The pre-computed time ranges of §3.26 describe the past. A collector
MUST NOT reuse them for streamed records.

For a **metric** condition, the selector has already been required to
resolve to exactly one series (§3.26). For each committed batch, the
collector finds that series' active sample at the batch's latest
candidate timestamp under §3.26's interval rules and evaluates the
condition against it. If no sample is active there, the condition is
false. A false condition filters out the whole batch; a true one leaves
the batch to be filtered by the remaining predicates as usual.

For an **existence** condition, the collector applies §3.26's centred
window to each candidate record's own timestamp. These are evaluated
**per record**, not per batch, because a matching record may be near
some of a batch and not the rest.

> [!NOTE]
> Evaluating the metric condition once per batch rather than once per
> record is an approximation, and an intentional one. A commit batch
> spans a fraction of a second while a metric sample spans fifteen, so
> every record in a batch normally maps to the same sample. At
> sub-second metric resolution it filters more coarsely than per-record
> evaluation would, and records near a threshold crossing are included or
> excluded as a group.

## 3.27.6 Backpressure

If a client cannot keep up, the collector MUST drop the query rather
than buffer for it.

Backpressure is detected on the socket send buffer: when a result
message cannot be sent because the buffer is full, the collector MUST
terminate the query immediately and MUST NOT block on the send. It sends
an error if the socket will still take one, and closes otherwise.

Streaming MUST NOT slow or block ingestion. A streaming client is the
lowest-priority consumer of a collector's time, and a slow one is
disconnected rather than accommodated — the same principle as §3.4,
applied on the way out.

## 3.27.7 Latency

Delivery latency is bounded below by the collector's commit interval for
the store concerned, because a record is only streamable once it is
committed. A client that needs lower latency than that is not served by
this interface: the KMES ring buffer is the lower-latency path and is
specified in PSPK.

> [!NOTE]
> Streaming is a convenience for interactive tailing and dashboards.
> Where results must be predictable — cross-type conditions evaluated per
> batch, a metric threshold sampled coarsely, a disconnect under load —
> repeated non-streaming queries with a sliding `SINCE` are more
> reliable, and for latency-critical consumption the ring buffer bypasses
> a collector entirely.

---

# 3.28 What a Client Cannot See

_Peios / Advanced Peios / PSPU / Observability Interfaces_

> Read access is enforced on every query against the token captured at connect — the unit of access, and why filtering is silent.

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`, `DISTINCT` and every aggregation
  reflect **only** authorized records. A count is a count of what the
  client may see.
- A cross-type condition referencing data the client may not read
  evaluates as though **no matching data exists** (§3.26). It does not
  fail the query.
- `TAKE` and `SKIP` page over the authorized records only.

## 3.28.3 Access control runs before everything

A collector MUST remove unauthorized records from the logical row set
**before** predicates, transforms, grouping, aggregation, sorting,
pagination and projection (§3.18).

This is not tidiness. Counting, ordering or paginating over records a
client may not read leaks them through the count, through the ordering,
and through the gaps in pagination — a client could establish how many
records of a type it cannot read exist, and roughly when, without ever
seeing one.

A collector MAY reach the result however it likes: pushing the
authorization down into its storage engine, or reading candidates and
discarding them before aggregating. What it MUST NOT do is produce a
different answer from the one filtering-first produces.

## 3.28.4 Denied fields do not fail the query

When a query references a field in a predicate, a grouping, a sort or an
aggregation, and some matching identifier does not grant that field, the
records under that identifier contribute nothing — exactly as if their
identifier had been denied outright.

A collector MUST NOT reject the query.

> [!NOTE]
> Rejecting would be the more informative behaviour and that is precisely
> the objection to it. A rejection tells the client that an identifier
> exists, matches its query, and carries a field it may not read — three
> facts about data it was not permitted to see, delivered by the
> mechanism meant to withhold them. A client could enumerate restricted
> event types by watching which queries are refused. Silence costs the
> client a result that is narrower than it looks; rejection costs the
> system the property the whole model rests on.

Authorization for a field is resolved from the field **as written**,
against each concrete identifier, and does not depend on whether any
record of that identifier actually carries it. Payload fields vary
between records of the same type, so a rule that turned on presence
would be undecidable before the scan it was meant to authorize.

## 3.28.5 What is not a field

Derived aggregate outputs — `count`, `sum`, `avg`, `min`, `max` — are
**not** source fields, have no access identity of their own, and are
visible whenever the client is authorized for the records and the source
fields they were computed from.

Values internal to preserving query semantics — row identifiers, series
identifiers, ordering tiebreakers, series type checks — are likewise not
query-language fields (§3.21). A metric result's `value` **is** a source
field, because it is a raw sample or a scalar derived from raw samples.

## 3.28.6 Errors say nothing

A collector MUST NOT include a value the client is not authorized to
read in any error message (§3.16), including in errors raised by
internal consistency checks.

## 3.28.7 Streaming

Access decisions made for the initial result set are reused during the
watch phase, but a collector MUST resolve any **new** concrete
identifier that appears in a streamed batch and check it before using
the record or its distinct value — a new event type or a new log origin
appearing mid-stream has never been authorized.

If a rule changes during a streaming query, a collector MUST re-check
subsequent batches against the new rule.

The **token** does not change. It was captured at connection (§3.14), so
a client whose group memberships change mid-stream continues to be
evaluated against what it connected with, and a client whose access is
revoked keeps receiving records until it disconnects.

## 3.28.8 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.

> [!NOTE]
> Closing this needs something the interface cannot supply on its own: a
> way to obtain the peer's token for a datagram, as a collector obtains
> one for a stream connection (§3.14). Until that exists, a producer's
> claim about itself is unverifiable, and confining the set of processes
> that can reach the socket at all is the only available control.

---

# 3.29 Extension

_Peios / Advanced Peios / PSPU / Observability Interfaces_

> There is no version number on any of the three interfaces — so unknown fields are ignored, unknown values are refused, and only some things may change.

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_

> Every requirement of this chapter collected by role — collector, producer and client — plus what is deliberately not required.

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 a collector must enforce, with the mainline collector's value and adjustable range, and the one relation that ties two of them together.

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 query language and where each construct is valid — shape, clause validity, rejected combinations, functions, operators and fields.

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

```text
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 well-known counterparts these three interfaces were shaped against, and where they sit relative to them.

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_

> The two interfaces a Peios service manager offers — the control channel and the notification channel — and what this chapter leaves out.

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_

> Terms this chapter defines for itself — service, job, operation and advisory — and the ones it borrows unchanged.

**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_

> Why the notification channel is a datagram socket and the control channel a stream, and how a caller reaches either.

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.

> [!NOTE]
> The failure is quiet in both directions and neither direction
> announces itself. A socket in a permissive place is reachable by
> anything, and a socket in a bare one is reachable by nothing, and in
> both cases the manager binds successfully, reports itself ready, and
> serves no one it meant to.

---

# 4.4 The Control Channel

_Peios / Advanced Peios / PSPU / Service Control and Notification_

> The manager's stream socket at a well-known path, what a connection is, and the limits placed on one.

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_

> One compact JSON object per newline-terminated frame, in both directions — what counts as malformed, and when the manager closes.

## 4.5.1 Frames

Every message in both directions is one frame: a single JSON object,
serialised compactly, followed by one `0x0A` byte. This applies to
requests and to responses alike, and the manager MUST terminate every
response with a newline.

Framing is byte-oriented and is performed before any JSON is parsed. A
`0x0A` byte ends the frame wherever it appears, so a raw newline inside
what a sender intended as a JSON string does not produce one frame with
an embedded newline — it produces two malformed ones. (A raw `0x0A`
inside a JSON string is not valid JSON in any case; the `\n` escape
sequence is unaffected and is the way to carry a newline in a value.)

The manager MUST NOT emit pretty-printed JSON, and MUST NOT emit more
than one object per frame.

## 4.5.2 Encoding

Frames are UTF-8. The manager MUST reject a frame that is not
well-formed UTF-8 with `MALFORMED_REQUEST`.

## 4.5.3 What is malformed

The manager MUST answer with `MALFORMED_REQUEST` when a frame:

- is empty — a bare newline with no content;
- is not well-formed UTF-8;
- is not valid JSON;
- is valid JSON but not an **object**. An array, a string, a number,
  `true`, `false` and `null` are all malformed requests.

## 4.5.4 Closing after an error

The manager MUST distinguish two classes of failure, because they say
different things about the connection.

A **frame-level** failure means the manager cannot trust the stream's
framing any more: it does not know where the next frame begins.
`MALFORMED_REQUEST` for an empty frame and `REQUEST_TOO_LARGE` are both
frame-level. The manager MUST send the error response, discard any
buffered input, and close the connection.

A **command-level** failure means the frame was well-formed and the
command in it could not be carried out: unparseable JSON content, an
unknown command, missing arguments, a denied access check, an unknown
service. The manager MUST send the error response and MUST keep the
connection open.

A client MUST NOT assume a connection survives an error response, and
MUST be prepared for either.

## 4.5.5 Timestamps

Every timestamp field the manager emits MUST be a UTC RFC 3339 string
with exactly **nine** fractional-second digits and the literal offset
marker `Z`:

```
"2026-06-01T12:34:56.123456789Z"
```

The manager MUST NOT emit a numeric offset in place of `Z`, and MUST NOT
vary the number of fractional digits.

These are wall-clock instants, presented for a reader. The manager MUST
NOT derive elapsed-time decisions — timeouts, retries, ordering — from
wall-clock differences, and a client MUST NOT assume that two timestamps
in the same response were taken from a clock that did not move between
them.

---

# 4.6 Peer Identity

_Peios / Advanced Peios / PSPU / Service Control and Notification_

> The manager establishes every client's identity from the kernel, at connect, and a client can never assert who it is.

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.

> [!NOTE]
> Capturing once is what makes the identity meaningful. A per-command
> capture would evaluate each command against whatever the peer happened
> to be at the moment the manager got round to reading it, which is a
> race a client could steer.

## 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 checked against a Security Descriptor using the peer's token — the rights, the per-command mapping, and why some results are filtered rather than denied.

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

1. If the manager is shutting down, apply §4.15's restriction. The
   shutdown restriction is evaluated **before** the access check, so a
   caller who would have been denied is told the command is invalid for
   the current state. A client MUST NOT infer anything about its own
   rights from an `INVALID_STATE` received during shutdown.
2. Resolve the target. A command naming no service the manager knows of
   MUST be answered `UNKNOWN_SERVICE`. The manager MUST NOT synthesise a
   descriptor for a service that does not exist.
3. Evaluate the access check with the peer's token, the target's
   descriptor, the appropriate generic mapping, and the required right.
4. 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.
5. 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_

> The fields a request JSON object carries, which apply to which commands, and the rules service names must satisfy.

A request is one JSON object.

```json
{"command": "start", "service": "jellyfin", "wait": true}
```

## 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_

> The four response shapes — acknowledgement, status, system and error — and what may be null in each.

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.

```json
{"status": "ok", "operation_id": "a1b2c3d4-…", "service": "jellyfin",
 "state": "active", "cause": "explicit_start", "warnings": []}
```

| 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`:

```json
{"status": "ok"}
```

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

```json
{"status": "error", "code": "ACCESS_DENIED",
 "message": "caller lacks SERVICE_START on jellyfin"}
```

`code` MUST be one of the values in §4.10. `message` is human-readable
and is not normative: a client MUST NOT parse it, match on it, or branch
on its content. Two managers answering the same request with the same
code MAY word the message differently.

## 4.9.5 Nullability

A field that does not apply to the current state MUST be present and
`null` rather than omitted, except where this chapter says otherwise.
A client MUST accept `null` for any field this chapter marks nullable,
and MUST NOT treat a `null` as an error.

The 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 closed set of error codes a manager may emit, the distinctions a client can rely on, and why the set cannot grow without a version bump.

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_

> The five commands that move a service through its state machine — none acting directly, each creating, merging into, queuing or cancelling an advisory.

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_

> The defined answer for every command sent to a service in an unexpected state — the manager never silently does nothing.

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:

- `start` defers, as above, and honours the remaining delay.
- `stop` cancels both the pending restart and any deferred start, and
  the service becomes inactive.
- `restart` cancels the automatic restart and performs a
  caller-initiated one.
- `reload` and `reset` are invalid: there is no process to reload and no
  terminal state to clear.

## 4.12.3 The `abandoned` column

Every lifecycle command except `reset` is invalid on an abandoned
service. `reset` clears it. Nothing else is meaningful while processes
the manager could not terminate are still present.

## 4.12.4 A service being withdrawn

A manager MAY keep supervising a service whose definition has been
removed while an instance of it is still running. In that condition the
manager MUST answer `start`, `restart` and `reload` with
`UNKNOWN_SERVICE`, MUST accept `stop`, and MUST report the condition in
the status shape (§4.14). This holds whatever the service's state.

---

# 4.13 Wait Semantics

_Peios / Advanced Peios / PSPU / Service Control and Notification_

> What ending a wait means, and how the wait flag changes when a lifecycle command responds.

`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_

> The three commands that read state and change nothing — status, list and operation-status — and how long results are retained.

Three commands read state and change nothing.

## 4.14.1 status

Returns everything the manager knows about one service.

```json
{
    "status": "ok",
    "service": "jellyfin",
    "state": "active",
    "cause": "explicit_start",
    "status_text": "Listening on port 8096",
    "current_job": {
        "id": "a1b2c3d4-…",
        "type": "service_main",
        "pid": 1234,
        "started_at": "2026-06-01T12:34:56.123456789Z",
        "identity": "jellyfin-svc"
    },
    "current_operation": {
        "id": "e5f6g7h8-…",
        "type": "start",
        "source": "admin"
    },
    "health": "healthy",
    "uptime_seconds": 86400,
    "definition_removed": false,
    "warnings": []
}
```

| 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:

```json
{"path": "/sys/fs/cgroup/peinit/jellyfin/health",
 "type": "health",
 "detected_at": "2026-06-01T12:34:56.123456789Z"}
```

| 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.

```json
{
    "status": "ok",
    "services": [
        {"service": "jellyfin", "state": "active",
         "cause": "explicit_start", "health": "healthy"},
        {"service": "registryd", "state": "active",
         "cause": "dependency_start", "health": null}
    ]
}
```

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.

```json
{
    "status": "ok",
    "operation": {
        "id": "e5f6g7h8-…",
        "type": "start",
        "service": "jellyfin",
        "source": "admin",
        "state": "completed",
        "result": "active",
        "merged_into": null,
        "error": null,
        "requested_at": "2026-06-01T12:34:56.123456789Z",
        "started_at": "2026-06-01T12:34:56.223456789Z",
        "completed_at": "2026-06-01T12:34:58.923456789Z"
    }
}
```

| 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_

> shutdown and reload-config — the two commands that act on the manager itself, and why reload is atomic but not a live update.

Two commands act on the manager rather than on a service.

## 4.15.1 shutdown

```json
{"command": "shutdown", "type": "reboot"}
```

`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.

```json
{
    "status": "ok",
    "summary": {
        "added": ["jellyfin"],
        "updated": ["sshd"],
        "restored": [],
        "marked_removed": ["old-migration"],
        "discarded": ["obsolete-timer"]
    },
    "warnings": []
}
```

| 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_

> The datagram socket a service reports on itself over — how it is addressed, how delivery works, and the bounds on it.

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_

> Newline-separated KEY=VALUE lines — what makes a datagram malformed, the three ways one line can fail, and why rejection is recorded rather than answered.

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 claims to be a service talking about itself; the steps by which a manager establishes that it is, and what it must never use.

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:

1. **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.
2. **That job has exec'd and is running.** A job still in setup has not
   become the service yet.
3. **That job has a kernel handle on the process** — a pidfd, or an
   equivalent that refers to one specific process rather than to a
   number.
4. **The handle still refers to the attested PID.** The manager MUST
   verify the PID against the handle rather than trusting the PID alone.
5. **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 — lifecycle, health, reporting and descriptor-store — and the ones deliberately not supported.

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_

> Handing file descriptors to the manager and getting them back after an unchosen restart — storing, removing, returning, and when the store is emptied.

A service may hand file descriptors to the manager and get them back
after a restart it did not choose. This is what lets a stateful daemon —
one holding a listening socket, say — restart without dropping what it
already had.

The manager MUST support a per-service maximum, which MAY be zero. Zero
disables the store for that service, and a service MUST NOT assume a
store exists.

## 4.20.1 Storing

On an authenticated datagram carrying `FDSTORE=1` with descriptors
attached, the manager MUST:

1. If the store is disabled for this service, **close** the descriptors
   and record the rejection.
2. If the store already holds its maximum, **close** the descriptors and
   record the rejection. It MUST NOT evict an existing entry — a full
   store is full, and silently discarding something the service is
   relying on to survive a restart would be worse than refusing the new
   one.
3. Store them under the value of `FDNAME` if present and non-empty, and
   under the name `stored` otherwise.
4. Note `FDPOLL=0` if present.

A datagram MAY carry several descriptors. Each becomes its own entry
under the one name, and each is independently subject to the maximum —
so a datagram carrying more than will fit has some stored and the rest
closed.

Several entries MAY share a name.

`FDPOLL=0` asks the manager not to monitor the descriptors for error
conditions. A manager MAY monitor stored descriptors and remove ones
that have become invalid; a manager that does not MUST still accept the
field.

## 4.20.2 Removing

`FDSTOREREMOVE=1` with `FDNAME` MUST remove every entry of that name and
close its descriptors. A name matching nothing is a no-op and MUST NOT
be an error.

`FDSTOREREMOVE=1` **without** `FDNAME` MUST be treated as a malformed
line, rejecting the whole datagram (§4.17). A remove with no name has no
defined meaning, and the alternative readings — remove everything,
remove the default name, do nothing — are far enough apart that guessing
between them silently is worse than refusing.

## 4.20.3 Returning them

When the service starts again, the manager MUST pass the stored
descriptors to the new process:

1. Placed consecutively, starting at descriptor **3**, with close-on-exec
   cleared.
2. `LISTEN_FDS` set to the number of descriptors passed.
3. `LISTEN_FDNAMES` set to the names, **colon-separated**, in the same
   order as the descriptor numbers.
4. `LISTEN_PID` set to the new process's own PID.
5. The store cleared.

`LISTEN_PID` is what lets a service verify that the variables are
addressed to it rather than inherited from an ancestor. A conforming
client checks it against its own PID before trusting `LISTEN_FDS`, and
treats a mismatch as meaning no descriptors were passed — so a manager
that omits it hands descriptors to a service that will not take them.

All four variables MUST be absent when no descriptors are passed, and
the manager MUST NOT allow any of them to be set by a configurable
environment layer. A `LISTEN_FDS` reaching a service that was passed
nothing points its descriptor-adopting code at whatever happens to be at
descriptor 3.

Descriptors are returned to the service's main process only. A hook or a
probe MUST NOT receive them.

The store MUST be cleared once the descriptors have been passed. The
manager MUST NOT clear it when a start attempt fails before that point —
the descriptors are still the service's, and the next attempt should get
them.

## 4.20.4 When the store is emptied

The manager MUST clear the store, closing its descriptors, when:

- the service is stopped deliberately — by a client, or as part of a
  system shutdown; or
- the service's definition is withdrawn and its entry is finally
  discarded.

The manager MUST NOT clear it on a restart the service did not ask for —
a crash, or a restart policy acting on one. That case is the entire
purpose of the mechanism: the descriptors survive exactly the restart
the service could not prepare for.

> [!NOTE]
> `LISTEN_FDS`, `LISTEN_FDNAMES` and `LISTEN_PID` are the convention
> established by systemd's `sd_listen_fds`, and are specified here in
> the same form deliberately. Software already written to adopt
> descriptors that way works against a Peios service manager without
> modification.

---

# 4.21 Extension

_Peios / Advanced Peios / PSPU / Service Control and Notification_

> Neither channel carries a version number — what may be added freely, what may not, and what a client must do with something it does not recognise.

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_

> Every requirement of this chapter collected by role — manager, client and service — and what conformance deliberately is not.

## 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 limits and defaults a Peios service manager uses on both channels, and which of them are configurable.

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 on the control channel — response status, service state, transition cause, health, job and operation types.

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_

> What this chapter specifies — the .peipkg artifact and the repository protocol that serves it — its roles, and what it leaves out.

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_

> The terms this chapter defines — package, manifest, root, repository — used with these meanings throughout.

- **Package** — a binary distribution artifact: one or more files,
  metadata describing its identity and relationships, and, when signed,
  a signature. The unit of build, distribution, and installation.

- **Manifest** — the JSON document at `.peipkg/manifest.json` inside a
  package that declares its identity, relationships, side-effect
  requirements, and build provenance. The manifest is authoritative for
  a package's metadata (§5.18).

- **Files manifest** — the JSON document at `.peipkg/files.json`
  carrying one content hash per regular payload file (§5.25).

- **Payload** — the tar entries of a package that are not metadata: the
  files, directories, and symlinks it installs.

- **Repository** — a collection of packages addressable as a unit,
  identified by its base URL.

- **Repository descriptor** — the small JSON document at a well-known
  path within a repository declaring its identity, signing keys, and the
  locations of its indexes (§5.31).

- **Index** — a signed JSON document listing packages available from a
  repository. Every repository publishes two: an **active index**
  (§5.33) listing the current version of each package, and an **archive
  index** (§5.35) listing every version ever shipped.

- **Virtual name** — a capability name, rather than a package name, that
  a package may require or provide (§5.4).

- **Role** — a virtual name that several installed packages may contend
  to own on the filesystem, with at most one *holding* it (§5.23).

- **Claim** — the binding of a contended filesystem name (a *claim
  path*) to a file supplied by the package that holds a role (a
  *target*).

- **Holder** — the single installed package that currently owns a role.
  A role with no holder is *unheld*.

- **Side-effect declaration** — a manifest flag naming a standard
  maintenance operation to be invoked after install, drawn from a closed
  set (§5.24).

- **Installation root** — a self-contained filesystem tree into which
  packages are installed. The default root is the system root; a system
  may define others (§5.19).

- **Epoch**, **upstream version**, **peios revision** — the three
  components of a version string (§5.5).

- **Trust anchor** — a key fingerprint supplied to a consumer
  out-of-band, against which a repository's descriptor signature is
  first verified (§5.37).

---

# 5.3 Package Names

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> What a package name may contain, how it is structured and cased, the filename convention, and the sub-package conventions.

A package's name identifies it within a repository and across every
repository that may serve it.

## 5.3.1 Character set

A package name MUST consist of ASCII characters drawn from:

- lowercase letters `a`–`z`
- digits `0`–`9`
- hyphen `-`
- period `.`
- plus sign `+`

A package name MUST NOT contain uppercase letters, whitespace,
underscores, or any character outside that set.

## 5.3.2 Structure

A package name MUST start with a lowercase letter or a digit, and MUST
end with a lowercase letter, a digit, or a plus sign.

The hyphen and the period are **separator** characters. The plus sign is
not a separator but an ordinary name character: it is intrinsic to names
such as `libstdc++` and `g++`, so it MAY repeat and MAY end a name.

A package name MUST NOT contain two consecutive separators — `--`, `..`,
`-.`, or `.-`.

A package name MUST be at least 2 and at most 64 characters long.

> [!NOTE]
> The character set admits the common upstream patterns: library
> suffixes (`libstdc++`), architecture prefixes (`lib32-foo`), and
> dotted module names (`python3.example`). Underscores are excluded so
> that the filename separator below stays unambiguous.

## 5.3.3 Case

Package names are case-sensitive. Because uppercase letters are
forbidden, this is equivalent to byte-for-byte equality.

## 5.3.4 Filename convention

A package file's name, on disk and in URLs, MUST be:

```
<name>_<version>_<architecture>.peipkg
```

The separator between fields is the underscore, and the extension is
`.peipkg`.

A filename is parsed by splitting at the **first** underscore and then
at the **second**: what precedes the first is the name, what lies
between them is the version, and what follows the second — up to the
`.peipkg` extension — is the architecture. The underscore MUST NOT
appear in the name (§5.3) or in the version (§5.5). It MAY appear in the
architecture (§5.8), and does in `x86_64`, which is why the architecture
field is defined as the remainder rather than as the text after the last
underscore.

Examples:

```
nginx_1.26.2-3_x86_64.peipkg
jq_1.7.1-2_x86_64.peipkg
peios-docs_0.22-1_noarch.peipkg
libstdc++_13.2.1-4_x86_64.peipkg
```

A consumer MUST NOT derive a package's identity from its filename. The
manifest is authoritative (§5.18); the filename is a convenience for
humans and for static hosting.

## 5.3.5 Sub-package conventions

Packages shipping related but separable content SHOULD use a
hyphen-suffix convention:

| 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_

> A capability expressed as a name rather than a package — the grammar, and why virtual and real names share one namespace.

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_

> The structure of a package version string — epoch, upstream version and Peios revision — and how it is parsed.

Every package carries a version string that identifies one build of that
package. Version strings have a defined structure and a defined
comparison order (§5.6), so that "newer" and "older" are unambiguous
across every implementation.

## 5.5.1 Structure

```
[<epoch>:]<upstream>-<peios_revision>
```

- **Epoch** — an OPTIONAL non-negative integer, separated from the rest
  by a colon. Absent means zero.
- **Upstream** — the version the upstream project assigned, or, for
  Peios-native software, the version Peios assigned as vendor.
- **Peios revision** — a REQUIRED positive integer identifying the build
  of this upstream version produced by the distributor.

```
1.26.2-3            upstream 1.26.2, revision 3
1.26.2-rc.1-1       upstream 1.26.2-rc.1, revision 1
2:0.5.0-1           epoch 2, upstream 0.5.0, revision 1
0.22-1              upstream 0.22, revision 1 (Peios-native)
```

## 5.5.2 Epoch

The epoch MUST be encoded as ASCII decimal digits with no leading zeros,
except that zero is encoded as the single digit `0`. The separator is a
single colon.

Epoch exists solely to override the natural ordering of upstream version
strings when an upstream regression makes a later release compare as
older than an earlier one. Bumping it SHOULD be a deliberate, documented
decision; a routine version update MUST NOT bump it.

> [!NOTE]
> An upstream project releases v2.0, abandons that line, and releases
> v0.5 as its new stable branch. Without an epoch, v0.5 compares as
> older than v2.0 and nobody on v2.0 can upgrade. Bumping the epoch to 1
> says "v0.5 in this epoch is newer than anything in epoch 0".

## 5.5.3 Upstream version

The upstream version is everything between the optional epoch separator
and the final hyphen preceding the revision.

It MUST consist of ASCII characters drawn from: letters `a`–`z` and
`A`–`Z`, digits `0`–`9`, period `.`, plus sign `+`, hyphen `-`, and
tilde `~`. It MUST start with a digit or a letter, and MUST NOT contain
whitespace or any character outside that set.

> [!NOTE]
> The set is permissive because upstream projects format versions in
> every way imaginable: numeric (`1.26.2`), hyphenated pre-release
> (`1.0.0-rc.1`), concatenated pre-release (`16beta1`), build metadata
> (`1.0+build.42`), and tilde-separated pre-release (`1.0~rc.1`).

## 5.5.4 Peios revision

The peios revision MUST be a positive integer encoded as ASCII decimal
digits with no leading zeros. It is incremented when the distributor
produces a new build of the same upstream version — a backported
security patch, a build-configuration change, a dependency bump, a
packaging fix.

The first revision of any upstream version MUST be `1`. Revision `0` is
reserved and MUST NOT appear in a published package.

## 5.5.5 Parsing

A version string is parsed as follows:

1. If the string contains a colon, split at the **first** colon: what
   precedes it is the epoch, what follows is the remainder. Otherwise
   the epoch is 0 and the remainder is the whole string.
2. Split the remainder at the **last** hyphen: what follows is the peios
   revision, what precedes is the upstream version.
3. The peios revision MUST parse as a positive integer.
4. The upstream version MUST satisfy the constraints above.

A version string that does not parse is invalid, and an implementation
MUST reject it.

## 5.5.6 Stability

The comparison algorithm of §5.6 is frozen. Any two conforming
implementations MUST produce identical comparison results for every pair
of valid version strings. An implementation that disagrees with another
on any such pair is non-conformant, whichever of the two is at fault.

---

# 5.6 Comparing Versions

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The three-stage comparison that decides which of two versions is newer, including pre-release segments and their rank.

Two version strings are compared in three stages:

1. Compare epochs as integers. If they differ, the higher epoch is
   greater.
2. If equal, compare upstream versions by the algorithm below.
3. If equal, compare peios revisions as integers. The higher revision is
   greater.
4. If all three are equal, the versions are equal.

## 5.6.1 Tokenising the upstream version

A tokeniser walks the upstream string left to right and emits segments:

1. The non-alphanumeric characters `.`, `+`, `-`, and `~` are separators
   and belong to no segment.
2. A maximal run of digits forms a **numeric** segment.
3. A maximal run of letters forms an **alphabetic** segment.
4. A transition between a digit and a letter ends the current segment
   and begins a new one.

## 5.6.2 Pre-release segments

A segment is a **pre-release segment** if it falls at or after the
earlier of:

- the first `~` separator — the tilde and every segment following it; or
- the first **recognised pre-release token**: a segment whose token
  carries a rank of 0 to 4 in the table below, that segment and every
  segment following it.

Once the pre-release tail begins it extends to the end of the upstream
version: every later segment is a pre-release segment, whatever the
separators between them. A `-` separator is an ordinary separator; it is
not itself a pre-release marker.

> [!NOTE]
> In `1.0.0-rc.1`, `rc` is recognised at rank 4, so `rc` and the
> following `1` are pre-release. In `16beta1`, `beta` is recognised at
> rank 2, so `beta` and `1` are pre-release with no separator involved.
> In `1.0-foo`, `foo` is alphabetic but not recognised: it is an
> ordinary rank-5 segment, and the `-` before it begins nothing.

| 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:

1. **Both numeric** — compare as integers. Leading zeros are
   insignificant.
2. **Both alphabetic** — compare by pre-release rank. When the ranks are
   equal:
   - at a rank of 0 to 4, the segments are **equivalent**. The table
     assigns several tokens to one rank as aliases, so two segments at
     the same recognised rank sort equal whichever alias appears.
   - at rank 5, the segments tiebreak by ASCII byte order against other
     rank-5 tokens.
3. **One numeric, one alphabetic** — the alphabetic segment is the
   lesser if the pair is a pre-release pair, and the greater if it is
   not. (Where only one of them is a pre-release segment, the rule above
   has already decided.)

> [!NOTE]
> ```
> 1.0~alpha-1 == 1.0~a-1       # both rank 1
> 1.0~beta-1  == 1.0~b-1       # both rank 2
> 1.0~Alpha-1 == 1.0~ALPHA-1   # case-insensitive recognition
> 1.0-foo-1   <  1.0-zzz-1     # rank-5 lexical tiebreak
> ```
> Two upstream versions that differ only in which alias they use are
> equal. A repository publishing both forms produces archive entries at
> the same logical version; the active index may carry either. A
> producer SHOULD pick one canonical form — conventionally the long
> token, `alpha` and `beta` — and keep to it.

## 5.6.5 Unequal lengths

When the segments of one version run out and every common segment
compared equal, the next segment of the longer sequence decides. Its
pre-release flag decides it, and its kind is irrelevant:

| Next segment in the 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_

> How a relationship restricts which versions satisfy it — the operators, how they combine, and revision-relaxed operands.

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.

> [!NOTE]
> This is what lets a dependency track a capability level rather than a
> packaging iteration. `libssl >= 3.0` is satisfied by `3.0-1` and by
> `3.0-7` alike; nothing about a repackaging of the same upstream
> release should change whether a dependency is met.

---

# 5.8 Architectures

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The architecture identifier, the defined set, the triplets derived from them, and what noarch means for installability.

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.

> [!NOTE]
> The `peios` suffix distinguishes Peios binaries from foreign-arch
> binaries originating elsewhere — Debian uses `gnu`, Alpine uses
> `musl`. It leaves room for a future multi-architecture system to host
> foreign-distribution binaries without filesystem-path collisions.

## 5.8.4 Architecture-independent packages

The `noarch` identifier denotes a package whose payload contains no
architecture-dependent content: documentation, configuration templates,
scripts in interpreted languages, or metadata only.

A package MUST NOT declare `noarch` if its payload contains compiled
binaries, shared libraries, or any other content whose semantics depend
on the target architecture.

## 5.8.5 Installability

Each Peios system has a single **primary architecture**, fixed at
install time.

- A package whose architecture equals the system's primary architecture
  MAY be installed.
- A package whose architecture is `noarch` MAY be installed on any
  system.
- A package whose architecture is neither MUST NOT be installed.

> [!NOTE]
> This version of the specification does not define multi-architecture
> systems — systems installing foreign-architecture packages alongside
> native ones. Running foreign-architecture binaries for emulation,
> cross-compilation, or legacy compatibility is addressed by mechanisms
> outside the package format. The triplet convention above applies
> regardless, so that a package conforming to this version stays
> forward-compatible with such an extension.

---

# 5.9 Document Conventions

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The rules every JSON artifact in this chapter obeys — parser hardening, hashes, signatures, strings, URLs and compression.

Every artifact this chapter defines — the manifest, the files manifest,
the signature envelope, the repository descriptor, and both indexes — is
a JSON document. The rules below apply to all of them.

## 5.9.1 JSON

Documents conform to RFC 8259 and are UTF-8 encoded (RFC 3629). Field
names are lowercase with underscores between words: `schema_version`,
never `schemaVersion`. Field order is not significant.

Unknown fields MUST be ignored on parse, so that the format can be
extended compatibly (§5.38). The signature envelope (§5.28) is the one
exception, and mandates strict parsing.

## 5.9.2 Parser hardening

A consumer's JSON parser processes attacker-supplied input. It MUST
therefore enforce the following, on every document defined in this
chapter:

- **Duplicate keys in any object MUST cause the document to be
  rejected.** A parser that silently takes first-wins or last-wins is
  not conformant.
- Integer fields MUST fit in the unsigned 64-bit range and MUST NOT use
  exponent notation.
- Nesting depth MUST be capped at 64; a document exceeding that depth
  MUST be rejected.
- A string value MUST NOT exceed the document size limit applicable to
  its containing artifact (§5.A).
- A Unicode escape within a string MUST resolve to a valid code point
  per RFC 8259 §7.

> [!NOTE]
> Duplicate-key rejection is the load-bearing rule of the list. A parser
> that takes last-wins and a parser that takes first-wins disagree about
> what a document says, which means the bytes a producer signed and the
> bytes a consumer acts on can differ while every signature still
> verifies. Several widely used JSON libraries take last-wins silently;
> conformance requires the rejection to be added deliberately.

## 5.9.3 Hashes

Hash values are encoded in lowercase hexadecimal unless stated
otherwise. Hash algorithms are identified by their IANA-registered names
(`sha256`, `blake3`).

## 5.9.4 Signatures

Signatures use Ed25519 as defined in RFC 8032 unless stated otherwise.
Signature values are encoded in base64 (RFC 4648 §4) **without padding**.
A base64 value carrying padding MUST be rejected.

## 5.9.5 Strings

String comparison uses byte-for-byte equality unless stated otherwise.

## 5.9.6 URLs

URLs follow RFC 3986. Relative URLs in a repository index are resolved
against the repository descriptor's URL (§5.36).

## 5.9.7 Compression

Compression uses the Zstandard format (RFC 8478). This specification
does not constrain the compression level.

---

# 5.10 The Container

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> A package is one Zstandard-compressed tar archive — the extension, tar format, compression settings, and what makes it streamable.

A package is a single file: a tar archive compressed with Zstandard.

## 5.10.1 Extension

A package file's extension MUST be `.peipkg`. There is no intermediate
`.tar` form; a producer emits the compressed form directly, and the
compressed file is the whole artifact.

## 5.10.2 Tar format

The tar archive MUST conform to the POSIX pax interchange format
(IEEE Std 1003.1-2017, Chapter 14).

> [!NOTE]
> pax supports arbitrary path lengths through extended headers. The
> older ustar format limits paths to 100 characters, or 255 with a
> prefix, which is not enough for some real packages. GNU tar,
> libarchive, and BSD tar all read and write pax by default.

## 5.10.3 Compression

The archive MUST be compressed with Zstandard (RFC 8478).

The compression level is at the producer's discretion. zstd is
deterministic at every level, so a producer MAY choose any level to
trade build time against on-wire size. Levels 19 and above, including
`--ultra`, increase build time substantially for a smaller result; level
3 is a common default.

## 5.10.4 Reproducibility

A package MUST be reproducible: given identical source inputs, an
identical build environment, identical metadata — including the build
timestamp recorded in the manifest — and identical compression
parameters, two independent producers MUST produce byte-identical
package files.

The determinism rules of §5.11 are what make this achievable at the
format level. They are necessary rather than sufficient: they constrain
what the archive looks like, not how the producer arrived at its
contents.

Byte-identity is a property of the **uncompressed** tar stream and of
the compression applied to it. This specification fixes the former
completely and the latter not at all: the compression level, the
Zstandard implementation, its version, and its frame parameters all
affect the resulting bytes and are none of them constrained here. Two
producers seeking byte-identical output MUST therefore agree on their
compression parameters out of band. What the format guarantees
unconditionally is that the *signed* bytes — the uncompressed tar
prefix of §5.28 — are identical, so a signature survives recompression
at any level.

## 5.10.5 Streaming

A consumer MAY process the archive as a stream. The internal layout
(§5.12) places metadata before payload precisely so that a consumer can
read a package's identity and reject a mismatched package without
buffering the payload.

## 5.10.6 No outer wrapping

The compressed archive contains tar entries and nothing else: no
enclosing directory, no concatenated archives, no container metadata
outside the tar entries themselves.

---

# 5.11 Determinism

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The rules a tar archive must obey to be byte-reproducible, every one of which a consumer rejects a package for violating.

To make a package byte-reproducible (§5.10), the tar archive MUST obey
every rule below. A consumer MUST reject a package that violates any of
them.

1. Tar entries MUST be ordered lexicographically by **the entry name as
   written into the tar header**, compared byte-for-byte over its UTF-8
   bytes. A directory's entry name carries a trailing `/`, and that
   slash participates in the comparison.
2. Every entry's modification time MUST equal the value of
   `build.timestamp` in the manifest (§5.18), which MUST NOT carry
   sub-second precision.
3. Every entry's owner numeric ID and group numeric ID MUST be 0.
4. Every entry's owner name and group name MUST be the string `root`.
5. Entries MUST NOT carry extended attributes. Security descriptors are
   applied at file-creation time (§5.20), never through tar attributes;
   other install-time attributes are applied through side-effect
   declarations (§5.24) or by higher-level mechanisms outside this
   specification.
6. Entry permission bits MUST be `0777` for every entry, with the setuid
   and setgid bits cleared (§5.16).
7. PAX extended header records, when present, MUST appear in a fixed
   canonical order: `path` first, then `linkpath` if present, then any
   other record sorted by record name lexicographically.
8. The tar header magic MUST be `ustar\0` and the version MUST be `00`.
9. The `devmajor` and `devminor` header fields MUST be 0 for every entry
   type this specification permits — none of which is a device entry.
10. Header padding bytes MUST be NUL (`0x00`).
11. PAX global header records (typeflag `g`) MUST NOT appear.
12. PAX extended header records (typeflag `x`) MUST appear only when an
    entry's `path` exceeds the ustar 100-byte limit, in which case a
    `path` record is emitted, or its `linkname` exceeds that limit, in
    which case a `linkpath` record is emitted. A record with any other
    key MUST NOT be emitted.
13. A path exceeding the ustar 100-byte limit MUST be carried by a
    `path` record. The ustar `prefix` field MUST NOT be used to split
    such a path across `prefix` and `name`.
14. An extended header entry's own name MUST be the containing
    directory's path, then `PaxHeaders.0/`, then the base name of the
    entry it describes. For an entry at the archive root the directory
    part is absent.

Rules 13 and 14 exist because a tar library given a long path may
legitimately choose either encoding, and either choice produces a
different byte stream from the same input. Determinism requires the
choice be made here rather than by whichever library a producer reached
for.

> [!NOTE]
> Rule 2's sub-second prohibition is not fussiness. A timestamp carrying
> a fractional part forces an `mtime` extended header onto every entry,
> violating rule 12 across the whole archive and changing the block
> count of the signature entry's own header — which is exactly the
> quantity a verifier uses to find the end of the signed range (§5.28).
> The symptom is a signature that does not verify, reported against the
> key rather than against the timestamp.

> [!NOTE]
> Rules 3, 4, and 6 together make a payload identity-free and
> permission-free: bytes in transit, carrying no claim about who may
> read them. What access control an installed file gets is decided at
> install time (§5.20), not declared by the package. §5.16 states the
> consequence for anyone extracting a package with a generic tar tool.

---

# 5.12 Internal Layout

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Metadata entries under the reserved prefix and payload entries beneath — which are required, in what order, and which entry types are permitted.

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:

1. `.peipkg/manifest.json`
2. `.peipkg/files.json`
3. Any optional metadata entries, sorted lexicographically by path
4. All payload entries, sorted lexicographically by path (§5.11 rule 1)
5. `.peipkg/signature`

The manifest comes first so that a streaming consumer can read a
package's identity and reject a mismatched one — wrong name, wrong
version, wrong architecture — before reading any payload.

The signature comes last because it signs everything preceding it
(§5.28). A consumer MUST reject a package in which any named entry
follows `.peipkg/signature`.

## 5.12.4 Optional metadata entries

A package MAY carry additional entries under `.peipkg/`. The set this
specification recognises is fixed at the three above; an unrecognised
entry under `.peipkg/` MUST be ignored on parse and MUST NOT prevent
installation.

When present, an optional metadata entry MUST appear between
`.peipkg/files.json` and the first payload entry.

> [!NOTE]
> A future version may introduce build attestations, reproducibility
> manifests, or a software bill of materials as further metadata
> entries. A producer targeting that future version MAY emit them into a
> package conforming to this one; a consumer conforming to this version
> ignores them.

## 5.12.5 Permitted entry types

A payload entry MUST be one of:

- a regular file (typeflag `0` or `\0`)
- a directory (typeflag `5`)
- a symbolic link (typeflag `2`)

Any other entry type MUST cause the package to be rejected. This
excludes hardlinks (typeflag `1`), character devices (`3`), block
devices (`4`), FIFOs (`6`), contiguous files (`7`), and every
vendor-specific type.

> [!NOTE]
> Hardlinks are excluded because they share an inode with their target,
> which would let a package install a payload entry aliasing an existing
> system file and so gain shared access to it. Kernel hardlink-creation
> permissions provide some defence, but excluding the entry type at the
> format level is simpler and removes the class entirely. Device, FIFO,
> and contiguous entries have no use in a package payload.

---

# 5.13 Payload Paths

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> A payload entry's tar path is its install location — the constraints on it, why nothing is canonicalised, and what an empty payload means.

A payload entry's tar path is its install location, resolved against the
installation root (§5.19). A tar entry at `usr/bin/nginx` installs to
`/usr/bin/nginx`.

## 5.13.1 Constraints

A payload path MUST:

- be relative — it MUST NOT begin with `/`
- contain no segment equal to `.` or `..`, or any encoding thereof
- be valid UTF-8 (RFC 3629)
- contain no NUL byte (`0x00`) and no ASCII control character
  (`0x01`–`0x1F`, `0x7F`)
- contain no backslash (`\`)
- be in Unicode Normalization Form C, per Unicode 16.0
- have every component at most 255 bytes when encoded as UTF-8
- be at most 4096 bytes in total when encoded as UTF-8
- have at most 256 components
- not begin with `.peipkg/`, and not be literally `.peipkg` (§5.12)

A consumer MUST validate every payload path against these constraints
**before any further processing of the entry**. A package containing a
non-conforming payload path MUST be rejected.

## 5.13.2 No canonicalisation

Path resolution MUST NOT canonicalise away `..` or `.` segments by
interpretation. Such segments are forbidden above; any appearance is a
format error, not a question of path canonicalisation.

> [!NOTE]
> The distinction matters. A consumer that normalises `a/../b` to `b`
> and proceeds has accepted a path this specification forbids, and has
> done so by a rule the producer never agreed to. Rejecting is the only
> behaviour both sides can predict.

## 5.13.3 Empty payloads

A package MAY have zero payload entries. Such a package carries only
metadata; installing it records the package and runs any declared side
effects (§5.24).

> [!NOTE]
> An empty-payload package is useful as a virtual aggregator: it ships
> no binaries of its own and exists to declare dependencies on a curated
> set of other packages.

---

# 5.14 Install Destinations

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The permitted top-level destinations, why package-owned storage under /usr is separate from the root-level runtime views, and the drop-in directories.

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).

> [!NOTE]
> What is absent from the list, and why:
>
> - `/bin`, `/sbin`, `/lib`, `/lib64`, `/libexec`, `/share`, `/include`,
>   `/etc`, `/conf` — **merged views**, computed from the trees beneath
>   them so that software sees one path while authority stays separated.
>   A package writes the `/usr` layer and the view does the rest.
> - `/lcl` — the **operator's** tree, the peer to `/usr` that is backed
>   up and survives reinstall. `/lcl/policy` in particular holds inputs
>   that grant authority; writing there yields arbitrary code at boot,
>   which is why packages are excluded structurally rather than by rule.
> - `/system` — **derived** from the image, registry, or platform, and
>   always reconstructible.
> - `/opt` — **operator territory**, deliberately off the list. Software
>   there brings its own layout, is invisible to package verification,
>   and participates in none of these guarantees.
> - `/dev`, `/proc`, `/sys`, `/run`, `/tmp` — kernel interfaces and
>   runtime-only directories; not storage a package can populate, not
>   even as empty directories.
> - `/srv`, `/data`, `/home`, `/media`, `/mnt` — operator and user
>   namespaces.
> - `/usr/local` — does not exist. `/usr` is meant to be untouchable,
>   and a writable subdirectory of it makes that a lie; `/lcl` is the
>   honest version.
> - `/root` — does not exist. There is no root.

## 5.14.3 Special system packages

A few packages exist precisely to lay down the structure these rules
protect — the base-filesystem package that mints the runtime mountpoint
tree is the archetype. For those, the allowlist is not a guardrail but
the thing being installed.

Such a package MAY set `special_system_package` in its manifest
(§5.18). The declaration waives the layout checks **at production time
only**. It grants nothing at install time: a consumer MUST refuse an
out-of-layout payload unless the operator has *also* explicitly opted
in, through a distinct and deliberate act naming that intent.

This is two keys held by two parties. A package may propose its own
exemption; only whoever installs it can grant one.

When a consumer meets the declaration without having been given the
opt-in, it MUST refuse the package with an error **naming the refused
request**, so that an operator can tell "this package asked for an
exemption I did not grant" from "this package is malformed".

`/lcl/policy` MUST NOT be reachable by this route under any
circumstance. It is the tree whose contents grant authority, and an
exemption that could reach it would convert a structural guarantee into
a policy one.

## 5.14.4 Drop-in directories

Several subdirectories of the `/etc` merge are **drop-in directories**:
their contents are interpreted as code or configuration by other tools,
notably the side-effect tools of §5.24 and system daemons that read
configuration drop-ins. A package writing into one has indirect
influence on the behaviour of components that read it.

A package from a repository other than the system's official repository
MUST NOT install a file at the top level of the `/usr/etc` layer of any
of these:

- `ld.so.conf.d/`
- `profile.d/`
- `sudoers.d/`
- `cron.d/`, `cron.daily/`, `cron.hourly/`, `cron.weekly/`,
  `cron.monthly/`
- `sysctl.d/`
- `modules-load.d/`
- `modprobe.d/`
- `binfmt.d/`
- any directory the system declares as a drop-in directory through its
  configured list

The consumer's drop-in directory list MUST be stored under a security
descriptor granting write access only to a recovery-class operator
principal, never to the principal performing installs. Operator
configuration MAY add entries to the list but MUST NOT remove an entry
this specification requires: the list is purely additive.

A non-official-repository package whose payload installs to one of those
paths MUST be rejected at install time.

A non-official-repository package MAY install drop-in files under its
own subdirectory of a drop-in path — for example
`/usr/etc/ld.so.conf.d/<repo-name>/<package>.conf` — provided the
subdirectory is namespaced by both the repository's name and the
package's name, so that two such packages cannot collide.

> [!NOTE]
> The restriction closes a concrete chain. A low-trust repository
> installs a configuration file at the top level of `ld.so.conf.d/` that
> extends the loader's library search path. At the next transaction that
> declares `ldconfig` — from any repository, not necessarily the same
> one — the drop-in is honoured and the loader's behaviour changes.
> Restricting top-level drop-in writes to the official repository, with
> a namespaced escape hatch for legitimate non-official content, breaks
> the chain without forbidding the use case.

---

# 5.15 The Architecture Triplet

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> What an architecture-specific package must install under /usr/lib/<triplet>/, the exemptions, and where architecture-independent data goes.

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.

> [!NOTE]
> A corresponding-source package conventionally lays out
> `/usr/src/dist/<name>-<version>/` with `upstream/` — the pristine
> source artifact, byte-identical to the producer's pinned input —
> `patches/` for the applied patch series, and `recipe/` for the
> build-controlling recipe files including the source lock that makes
> the shipped upstream artifact verifiable. This is a producer
> convention, not a format rule.

## 5.15.3 Architecture-independent data

`/usr/share/` holds architecture-independent files shared across every
architecture of a system: documentation, man pages, locales,
configuration templates, and static data such as icons, images, and
fonts.

Both `noarch` and arch-specific packages MAY install under
`/usr/share/`. A file installed there by an arch-specific package MUST
be byte-identical across every architecture build of the same upstream
version.

---

# 5.16 Payload Entries

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Tar permission bits are distribution metadata and establish no access control — plus empty directories, path ownership, and forward compatibility.

## 5.16.1 Permissions

Tar entry permission bits in a package are distribution-format metadata
only. They establish no access control on the installed file. Access
control is the consumer's responsibility: on Peios, through a security
descriptor applied at file-creation time (§5.20); on any other system
extracting a package for inspection or migration, through that system's
native mechanism applied after extraction.

Every payload entry's permission bits MUST be `0777`. Any other value
MUST cause the package to be rejected.

The setuid and setgid bits MUST NOT be set on a payload entry.
Privilege escalation on Peios is mediated by the kernel's access-control
subsystem, not by filesystem-level setuid; a setuid bit is meaningless
to the access-check path and MUST NOT appear in installed content.

> [!NOTE]
> The `0777` rule is honest signalling. A `0644` or `0755` mode would
> imply a permission contract the format does not enforce and the kernel
> does not consult. Fixing every entry to `0777`, combined with the
> uid/gid and owner/group rules of §5.11, treats a payload as
> identity-free, permission-free transport bytes.
>
> One consequence: extracting a package with a generic tar tool on a
> non-Peios host produces world-writable files. This is intended.
> Tooling that needs sensible host-native permissions on extracted files
> is responsible for applying them after extraction.

## 5.16.2 Empty directories

A package MAY install an empty directory: a tar entry of type directory
with no content. Empty directories establish paths a runtime will need,
and are the only content permitted under `/var/` (§5.14).

## 5.16.3 One package per path

Two packages MUST NOT install a file at the same install path. A
consumer MUST detect the collision and reject the second install.

A package MAY install content into a directory another package created;
directory creation is idempotent. The rule applies to non-directory
entries only.

The one exception is a claim link (§5.23), which belongs to the consumer
rather than to any package and is materialised only at a path no
installed package owns.

## 5.16.4 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 as first-class payload entries — target constraints, cross-package targets, how they are covered by integrity, and their descriptors.

Symlinks are first-class payload entries. The tar entry's linkname is
the symlink target.

## 5.17.1 Target constraints

A symlink target MUST be a relative path.

A symlink target MUST resolve, when joined with the symlink's parent
directory, to a path that is either within the package's own payload
tree or under one of the permitted top-level install destinations of
§5.14. An absolute target is forbidden, as is a target whose resolution
escapes those destinations entirely.

A symlink target is subject to the same path-validity constraints as a
payload path (§5.13): valid UTF-8, no NUL bytes, no ASCII control
characters, no backslashes, NFC normalisation, and the length limits.

A consumer MUST validate every symlink target against these constraints
before extracting the entry. A package containing a non-conforming
symlink target MUST be rejected.

> [!NOTE]
> Format-level validation cannot distinguish a symlink whose resolved
> target is an installable path that no package actually installs. A
> target reached by `..` traversal into a permitted destination passes
> validation and produces a dangling symlink; one that would overwrite
> an existing owned file is caught by the one-package-per-path rule
> (§5.16). Both are consumer-side outcomes, not format errors.

## 5.17.2 Cross-package targets

A producer MAY emit a symlink whose target resolves into a different
package's payload tree, provided the resolved path is under a permitted
destination. The canonical case is the conventional library split, where
a `-dev` package ships a developer link (`libfoo.so`) whose target
(`libfoo.so.1`) lives in the corresponding runtime package.

A producer SHOULD declare the target's owning package as a dependency,
so that the target is present at extraction time. The format does not
record this relationship at the symlink level; it is captured at the
package level through `dependencies` (§5.21).

> [!NOTE]
> Prohibiting cross-package symlinks was considered — it would have
> forced restructured `-dev` splits with developer links living in
> runtime packages — and rejected. Forbidding them deviates from
> universal Linux convention with no corresponding security gain: the
> defence below operates on resolved-target validity, not on whether the
> resolution crosses a package boundary.

## 5.17.3 Integrity

A symlink has no content body, and so is not hashed in the files
manifest (§5.25). Its target is integrity-checked directly: the linkname
stored in the tar header is what the consumer compares, and that header
is inside the signed bytes (§5.28).

## 5.17.4 Security descriptors

A symlink does not carry an independent security descriptor. Access to a
symlink is governed by access to its target. A security descriptor
override (§5.20) MUST NOT target a symlink entry.

> [!NOTE]
> The classic symlink TOCTOU attack — install `/usr/share/foo` as a
> symlink to a system file, then have a later write at
> `/usr/share/foo/bar` traverse it — is prevented in two layers. The
> format forbids symlink targets resolving outside the managed tree, and
> extraction resolves every path component without following a symlink,
> so even an in-tree symlink cannot redirect a later write.

---

# 5.18 The Manifest

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The authoritative JSON metadata for a package — identity, relationships, side-effect requirements and build provenance — with its full schema.

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

```json
{
  "schema_version": 1,
  "name": "<string>",
  "version": "<string>",
  "architecture": "<string>",
  "description": "<string>",
  "license": "<string>",
  "homepage": "<string>",
  "default_root": "<root reference>",
  "special_system_package": <bool>,
  "dependencies": [<dependency>...],
  "optional_dependencies": [<dependency>...],
  "conflicts": [<dependency>...],
  "provides": [<provides>...],
  "replaces": [<replaces>...],
  "side_effects": [<string>...],
  "size_installed": <integer>,
  "sd_overrides": [<sd_override>...],
  "build": {<build>}
}
```

## 5.18.2 Required fields

| 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

```json
{
  "timestamp": "<RFC 3339 timestamp>",
  "farm_id": "<string>",
  "source_ref": "<string>"
}
```

| 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
```

> [!NOTE]
> The build object exists to make reproducibility verifiable: a third
> party in possession of the build inputs and the recorded timestamp can
> re-run the build and compare the output bytes. The format does not
> mandate that verification; it supplies the inputs for it.

## 5.18.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.

> [!NOTE]
> The byte-range rule is deliberately cruder than a Unicode-category
> test. `description` is displayed to an operator deciding whether to
> install, so it is both a terminal-escape-injection surface and a
> homoglyph surface. A hard ASCII whitelist closes both without anyone
> having to reason about which Unicode categories are safe to render.

`license`, when present, SHOULD be a valid SPDX expression. A producer
MAY use another form; this specification does not validate license
strings.

`homepage`, when present, MUST be a syntactically valid URL per RFC 3986
and MUST use the `https` or `http` scheme. Any other scheme MUST cause
the package to be rejected.

`size_installed` MUST be a non-negative integer, and MUST equal the sum
of the `size` fields of every entry in the files manifest (§5.25). A
consumer MUST verify that equality and MUST reject a package where it
does not hold.

> [!NOTE]
> `size_installed` is not merely advisory. It is the input to the
> decompression bound of §5.27, so a package whose declared size is
> smaller than what it actually unpacks to is a resource-exhaustion
> attempt, and one that is larger inflates the bound. Tying it to a
> quantity a consumer can independently compute is what makes it
> trustworthy.

## 5.18.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.

> [!NOTE]
> The manifest's bytes are inside the archive that gets hashed and
> signed, so two semantically identical manifests with different
> whitespace produce different packages. Pinning the serialisation is
> what makes independent reproduction possible at all; leaving it to the
> producer's discretion means only that producer can ever reproduce its
> own output.

---

# 5.19 Installation Roots

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> A self-contained tree packages install into — the default root, named roots, and why dependency satisfaction is per-root.

An **installation root** is a self-contained filesystem tree into which
packages are installed. The default root is the system root; a system
MAY define additional **named roots** — an initramfs image built and
maintained alongside the main system is the motivating case.

How roots are registered, and how a name resolves to a filesystem
location, is consumer mechanics and is not part of the package format.

## 5.19.1 Root references

A **root reference** is the string form by which a manifest names a
root. Within a manifest a root reference MUST be a **named reference**:
one or more segments separated by `.`, where each segment matches
`[a-z0-9][a-z0-9_-]*`. Nesting is expressed by further segments, so
`initramfs.subroot` names the root `subroot` registered within the root
`initramfs`.

A root reference in a manifest MUST NOT be an absolute or relative
filesystem path. A package names roots and never dictates a filesystem
location: placement is the installing system's prerogative.

A manifest whose root reference is not syntactically valid is invalid
and MUST cause the package to be rejected. Whether the named root
*exists* is a consumer-side resolution concern, not a format-validity
one.

## 5.19.2 `default_root`

The manifest's `default_root` field (§5.18) governs **only** the
placement of a *top-level* install of the package — an operator request
naming this package directly, with no explicit root.

It has no effect when the package is pulled in as a dependency;
dependency placement is governed by the depending package and by the
dependency's own `root` field (§5.21). An explicit operator-supplied
root always overrides `default_root`.

## 5.19.3 Satisfaction is per-root

The identity of a satisfier is the pair **(name, root)**. The same
package name installed in two different roots is two independent
satisfactions, possibly at different versions, and a dependency is
satisfied only by an installation in the named — or defaulted — root.
A `constraint` or architecture qualifier is evaluated against that
installation.

> [!NOTE]
> Cross-root dependencies let a root be composed through the dependency
> graph. An initramfs package may depend on ordinary packages — a shell,
> core utilities — and have them placed into the initramfs root, either
> implicitly by living in that root itself or explicitly through the
> dependency's `root` field. The depended-on package declares no root
> affinity of its own; where it lands is the depender's and the
> operator's choice. This generalises the fixed build-host/target split
> other systems draw to an open set of named roots.

---

# 5.20 Security Descriptor Overrides

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Installed files inherit their descriptor by default — how a package declares an override, and the consumer's obligation when it does.

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:

```json
{
  "path": "<payload-relative path>",
  "sd": "<base64-encoded security descriptor>"
}
```

| 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:

1. Before applying any override, the consumer MUST surface it to the
   operator in human-readable form, including the payload path, the
   principals and rights granted, and a diff against what inheritance
   would have produced.
2. For a package from the system's official repository, overrides MAY be
   applied without per-operation confirmation, but the operator-visible
   install report MUST list every override applied.
3. For a package from any other repository, the consumer MUST require
   explicit operator confirmation before applying an override that
   grants rights to a principal outside a configured allowlist. The
   default allowlist contains the well-known system principals, plus any
   principal the operator has added to that repository's allowlist. It
   MUST NOT contain any principal derived from the package itself —
   from its manifest fields, its build metadata, or its payload. A
   package cannot elect its own principals into the allowlist.
4. A package whose overrides the policy rejects MUST be refused. A
   consumer MUST NOT silently drop the overrides and proceed with
   inheritance defaults.

## 5.20.4 Inherited descriptors are covered too

The policy applies both to explicitly declared descriptors and to
descriptors that *result from* inheritance from a directory whose own
descriptor was declared by any package's overrides.

Specifically: when installing a file under a directory whose descriptor
was overridden by any package — from any repository — the resulting
inherited descriptor MUST pass the policy as if the installing package
had declared it.

> [!NOTE]
> Without this, package A declares an override on a directory that
> grants A's author rights, and package B's files installed under that
> directory silently inherit it with the operator never prompted. The
> check fires on *what descriptor ends up on the file*, regardless of
> how it got there — which also closes the case where a
> carefully-mimicked "looks like the default" override would have
> evaded a test for non-default descriptors.

## 5.20.5 Failure

If file creation fails because the kernel rejects the descriptor — most
often because it references a principal the system does not know — the
install MUST be treated as failed and any partial state rolled back.

A package MUST NOT be installed into a parent directory whose descriptor
denies the caller the access required to create the entry. A consumer
detects this at install time and treats it as an install failure.

---

# 5.21 Relationships

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The five manifest fields expressing what a package needs, conflicts with, provides and replaces — with their constraints.

A package expresses its relationships to other packages in five manifest
fields:

- `dependencies` — packages that must be installed for this one to
  function
- `optional_dependencies` — packages that enhance functionality but are
  not required
- `conflicts` — packages that must not be installed alongside this one
- `provides` — virtual names this package satisfies on behalf of
  dependencies declared elsewhere
- `replaces` — packages this one supersedes

## 5.21.1 Dependency entries

An entry in `dependencies` or `optional_dependencies`:

```json
{
  "name": "<package or virtual name>",
  "constraint": "<version constraint>",
  "arch": "<arch qualifier>",
  "root": "<root reference>",
  "claims": { "<slot>": { "path": "<absolute path>" } }
}
```

| 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.

> [!NOTE]
> A future version may permit explicit architecture identifiers here, to
> support multi-architecture systems. Reserving the field now is what
> makes such an extension parse compatibly.

## 5.21.4 Provides entries

```json
{
  "name": "<virtual name>",
  "version": "<version string>",
  "claims": { "<slot>": { "target": "<absolute path>",
                          "path": "<absolute path>" } }
}
```

| 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.

> [!NOTE]
> The attack the warning exists for is concrete: a package at version
> `1.0-1` declaring `provides: [{name: libfoo, version: "5.0"}]`
> satisfies a dependency on `libfoo >= 4.0` and gets installed in place
> of a real `libfoo` — most easily when the real one is absent from the
> candidate set, which is precisely the shadowing case.

The provides relation does not flow transitively: providing
`smtp-server` does not provide whatever `smtp-server` itself provides.

## 5.21.5 Replaces entries

```json
{
  "name": "<package name>",
  "constraint": "<version constraint>"
}
```

`name` is required and MUST conform to the package-name grammar (§5.3).
`constraint` is optional; absent means this package replaces any version
of the named one.

A replaces entry expresses supersession. During upgrade the replaced
package is removed and this one installed in its place: files owned by
the replaced package that no longer exist in this one are removed, and
files existing in both are updated.

A replaces entry does not imply a conflict. A package MAY both replace
and conflict with the same target, but a replaces entry is typically
sufficient on its own.

> [!NOTE]
> Replaces is the rename mechanism. When `nginx-core` becomes `nginx`,
> the new `nginx` declares `{ "name": "nginx-core" }` in `replaces`, and
> existing systems transition on their next upgrade.

## 5.21.6 Field constraints

Each of the five fields is an array of objects matching the appropriate
schema. `dependencies` and `conflicts` MUST be present, and MAY be
empty. The other three MAY be omitted, which is equivalent to an empty
array.

Within a single field, entries MUST be sorted lexicographically by
`name`, and two entries MUST NOT carry identical `name` values. A
package with several constraints on one target MUST combine them into
that entry's single `constraint` string.

## 5.21.7 What satisfies a dependency

A dependency is **satisfied** by a candidate package when all of the
following hold:

1. The candidate's name equals the dependency's `name`, **or** the
   candidate has a `provides` entry whose name equals it.
2. If the dependency carries a `constraint`, the version satisfies it —
   the candidate's own version when matched by name, and the matching
   `provides` entry's version when matched through `provides`.
3. The candidate's architecture satisfies the `arch` qualifier.
4. The candidate is installed, or is being installed, in the
   dependency's root (§5.19).

A conflict is **triggered** by a candidate when the same conditions hold
with respect to a `conflicts` entry.

A `claims` field has no effect on satisfaction. A dependency on a role
is satisfied by any installed eligible provider regardless of which one
currently holds the role; claims govern which installed file owns a
contended filesystem name, nothing more (§5.23).

---

# 5.22 Derived Capabilities

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Capabilities derived mechanically from built contents — shared libraries and pkg-config modules — and why derivation is the producer's job.

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.

> [!NOTE]
> A producer that derives from ELF metadata will encounter cases this
> section does not name: a symlink that carries no `DT_SONAME` of its
> own, a shared-library-shaped file with no soname at all, symbol
> version tokens from `DT_VERNEED` that could be turned into a version
> constraint. Any additional constraint a producer synthesises is a
> constraint like any other and is evaluated by §5.7; what this section
> forbids is inventing a different *name* for a capability that has one.

---

# 5.23 Claim Declarations

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> How several installed packages contend for one shared filesystem name with exactly one owning it — the vocabulary, eligibility, and the consumer's part.

`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 `name` of a `provides` or dependency
  entry carrying a `claims` field.
- **Slot** — a named channel within a role. Each slot materialises one
  filesystem name. A role has one or more slots.
- **Claim path** — the absolute path a slot materialises at. A slot MAY
  have more than one.
- **Target** — the file a claim path points at while a given provider
  holds the slot. The target is a payload file of the holding package.
- **Holder** — the single installed package that currently owns a role.
  A role with no holder is *unheld*.

> [!NOTE]
> The two halves of a claim are declared by two different parties. The
> *consumer* — whatever hard-codes `/usr/bin/registryd` and expects to
> find a registry daemon there — declares the path. The *provider*
> declares the target, the file of its own that should answer it. The
> consumer joins the two by (role, slot). Splitting the declaration this
> way puts the path where the dependency on it lives and the target
> where the implementation lives.

## 5.23.2 The `claims` field

A claim is declared by adding a `claims` field to a dependency entry, an
optional-dependency entry, or a `provides` entry. It maps a slot name to
a slot descriptor:

```json
"claims": {
  "<slot name>": { "path": "<absolute path>",
                   "target": "<absolute path>" }
}
```

A slot name MUST conform to the package-name grammar (§5.3).

Which of the two descriptor fields is permitted depends on where the
`claims` field appears:

- On a **dependency** or **optional-dependency** entry — the consumer
  side — each slot descriptor MUST contain `path` and MUST NOT contain
  `target`. A consumer declares only where it expects the name; it
  supplies no implementation.
- On a **`provides`** entry — the provider side — each slot descriptor
  MUST contain `target` and MAY contain `path`. A provider declares the
  file that answers the slot, and MAY additionally declare a default
  claim path.

A `claims` field MUST NOT appear on a `conflicts` or `replaces` entry.

Slot keys within a `claims` object are an unordered JSON object and
carry no ordering requirement. The enclosing arrays remain sorted and
unique by `name` (§5.21).

Example — one package consumes the role, another provides it:

```json
// the consumer's manifest
"dependencies": [
  { "name": "registryd",
    "claims": { "binary": { "path": "/usr/bin/registryd" } } }
]

// the provider's manifest
"provides": [
  { "name": "registryd",
    "claims": { "binary": { "target": "/usr/sbin/loregd" } } }
]
```

## 5.23.3 Where a target may point

A `target` MUST name a path the declaring package itself installs as a
payload entry, and MUST therefore lie within the permitted install
destinations of §5.14. A `target` that does not correspond to one of the
declaring package's own payload paths is invalid and MUST cause the
package to be rejected.

A consumer MUST verify this itself, against the payload it actually
received. Producer-side validation says nothing about a package built
elsewhere.

## 5.23.4 Where a claim path may lie

A claim path is not a payload entry — it is the location of a
consumer-managed link — and is governed by its own rule. It MUST satisfy
the payload path-syntax and safety constraints of §5.13, and it MUST lie
in one of:

- the permitted install destinations of §5.14;
- under `/run/`; or
- the well-known root-level name `/init`.

Any other location MUST cause the package to be rejected.

> [!NOTE]
> A link location and a target are constrained differently because they
> are different kinds of object. A target is a real file the provider
> ships, so it obeys the ordinary payload rules. A claim path is a name
> the consumer owns and points at that file; permitting `/run/` for it —
> without permitting packages to ship payload there — lets a role expose
> a runtime socket while keeping `/run` off-limits to package payloads.
> `/init` is admitted for the same reason in the other direction: it is
> a well-known file a provider must own directly, and it is not a
> payload destination.

`/lcl/policy` MUST NOT be reachable as a claim path under any
circumstance, by the same rule and for the same reason as §5.14.

## 5.23.5 Eligibility

A package is an **eligible provider** of a role when it has a `provides`
entry whose `name` is the role and whose `claims` field declares a
`target` for at least one of the role's slots. Only an eligible provider
may hold a role.

A package that depends on a role and declares a claim path for it, but
does not provide the role, is a **consumer only**: it contributes claim
paths and can never hold.

## 5.23.6 What a consumer guarantees

The materialised links for a role MUST at all times equal the
cross-product of the role's computed claim paths with the holder's
targets, where the computed claim path set for a slot is the union of:

- every `path` declared for that slot by an installed consumer, and
- the `path` declared for that slot by the holder's own `provides`
  entry, if present.

A consumer MUST re-evaluate that set within any transaction that changes
its inputs — a change of holder, or the installation or removal of any
package declaring a claim path or a target for the role. A claim path
declared for an already-held role MUST be materialised retroactively
against the current holder; the holder is not re-decided.

A role MAY be held with no materialised links at all, when its computed
path set is empty. Holder state is therefore recorded independently of
whether any link exists.

A claim link is owned by the consumer, not by any package. It MUST NOT
appear in any package's payload and MUST NOT be recorded as a
package-owned path. This is what lets two eligible providers coexist:
neither ships the contended path, so the one-package-per-path rule
(§5.16) is never engaged by the providers themselves.

A claim path MUST NOT collide with a path owned by any installed
package, evaluated against the state the containing transaction will
produce rather than the state it started from. On collision,
materialisation MUST fail and the transaction MUST be rolled back.

A holder swap MUST repoint every one of the role's links within a single
transaction, and each repoint MUST be atomic, so that no consumer of a
claim path ever observes the path absent.

> [!NOTE]
> Atomic repoint matters because a claim path is typically on the
> critical path of a running system: a contended daemon binary may be
> executed at any moment. Tearing the old link down and building the new
> one as two steps exposes a window in which the name does not exist.

## 5.23.7 What claims are not

- Not a general symlink mechanism. A package needing a fixed symlink
  among its own files ships a payload symlink entry (§5.17). Claims
  exist for names contended by several packages.
- Not a service-registration or activation mechanism. A materialised
  claim link is a symlink and nothing more.
- Not an input to dependency resolution (§5.21).
- Not a way to escape the one-package-per-path rule for ordinary payload
  files. Only consumer-owned claim links are exempt, and only at paths
  no package owns.

---

# 5.24 Side-Effect Declarations

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The closed set of maintenance operations a package may request after install or removal — depmod and man-db — and how they are hardened, plus why Peios needs no shared-library cache.

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:

```json
"side_effects": ["depmod", "man-db"]
```

Each string MUST be drawn from the set below. An unknown value is
invalid and MUST cause the package to be rejected. The array MUST NOT
contain duplicates. It MAY be empty, or omitted entirely, for a package
requiring no maintenance operation.

## 5.24.2 The recognised set

### 5.24.2.1 `depmod`

Rebuilds the kernel module dependency cache for a kernel release.

A package MUST declare `depmod` if its payload contains kernel module
files (`.ko`, `.ko.*`) under `/usr/lib/modules/`. A package MUST NOT
declare it if its payload contains no kernel modules.

The consumer MUST invoke it once **per affected kernel release**, naming
that release. A package shipping modules for two releases causes two
invocations.

> [!NOTE]
> Naming the release matters more than it appears to. An invocation with
> no release operand acts on the *running* kernel, which during a kernel
> update or an image build is precisely not the kernel whose modules
> were installed — leaving that release's dependency cache unbuilt and
> its modules unloadable.

### 5.24.2.2 `man-db`

Rebuilds the man page index, so that lookups by keyword are fast.

A package SHOULD declare `man-db` if its payload contains man pages
under `/usr/share/man/`.

> [!NOTE]
> `man-db` is SHOULD rather than MUST because man page lookup degrades
> gracefully without it — queries fall back to a filesystem scan. A
> package omitting it is suboptimal, not broken.

## 5.24.3 Semantics

A side effect MUST be idempotent: running it several times in succession
MUST leave the system in the same state as running it once. The
recognised set has this property by construction.

A side effect MUST be safe to invoke non-interactively.

A consumer MUST invoke each declared side effect **once per
transaction**, after every file operation in that transaction is in
place and after the transaction has committed. Side effects MUST be
deduplicated across the packages in a transaction: several packages each
declaring `man-db` cause one invocation, not several.

A consumer MUST also invoke a side effect when a transaction *removes*
files whose absence affects that effect's target — removing a kernel
module requires `depmod`, removing a man page requires `man-db` —
whether or not any package in the transaction declared it.

## 5.24.4 Why there is no shared-library cache

Other systems carry an `ldconfig` side effect to rebuild
`/etc/ld.so.cache`. Peios has no such cache and no such side effect, and
this is a property of the layout rather than an omission.

A cache exists to do two things: make lookup fast when the loader must
search many directories, and let it find libraries in directories it
would not otherwise search. Peios has neither problem. The C library is
configured with its library directory, its system library directory and
its runtime-loader directory all set to `/usr/lib/<triplet>`, and the
loader carries that path compiled in as its default. **There is exactly
one shared-library directory, and it is the one the loader already
searches.**

So the rule that replaces the declaration is a layout rule, and it is
normative: a package shipping a shared library MUST install it into
`/usr/lib/<triplet>`. A library installed anywhere else will not be
found, and no maintenance operation exists to make it findable.

Reintroducing a cache would mean reintroducing everything a cache brings
with it — a file to keep coherent with the filesystem, a tool in the
base to regenerate it, and a failure mode where the two disagree. That
trade is only worth making if Peios ever needs more than one library
directory.

## 5.24.5 Ordering

Side effects are invoked in an implementation-defined order. The
recognised set is chosen so that order between distinct effects is not
significant, and a consumer MAY invoke them concurrently.

## 5.24.6 Invocation hardening

A consumer MUST invoke a side-effect tool with:

- a **fixed absolute path** to the tool. The set is closed, so the
  consumer knows each tool's location; it MUST NOT search a path
  variable.
- a **cleared environment** containing only well-defined variables.
  Environment inherited from the invoking context MUST NOT be passed
  through.
- **standard input closed**.

> [!NOTE]
> If a consumer located the tool by searching a path variable, a package
> could shadow the intended tool through an inherited search path.
> Because the set is closed the consumer needs no configurable
> allowlist — it invokes a known path. A cleared environment closes the
> matching injection route.

A consumer MUST invoke the tool against the **installation root the
transaction acted on**, not against the root the consumer itself is
running from.

## 5.24.7 Failure

Side effects run after the transaction commits, so a side-effect failure
does not — and cannot — roll the transaction back. A consumer MUST
report the failure to the operator; the transaction stands.

Because side effects are idempotent, a failed one is self-correcting:
re-invoking it, explicitly or as part of the next transaction that
declares it, reaches the correct state. A consumer SHOULD make
re-invocation straightforward.

> [!NOTE]
> Rolling a committed transaction back because a cache rebuild exited
> non-zero would be disproportionate: the packages installed correctly
> and only a cache lagged, recoverably.

## 5.24.8 Extension

A future version MAY recognise further identifiers — likely candidates
include `update-mime-database`, `update-desktop-database`, and
`udev-reload`, all excluded here as irrelevant to the scope Peios is
built for. A conforming implementation of this version MUST reject a
manifest declaring any identifier outside the set above.

A future version introducing a new identifier MUST state whether it is
order-independent with respect to the existing set. An order-dependent
side effect, if one is ever added, MUST be specified with a normative
ordering relative to every other recognised effect.

## 5.24.9 What side effects are not

- Not a general install-script mechanism. The closed enumeration is what
  prevents arbitrary code execution at install time.
- Not a way to register a service with the init system. Service
  integration belongs to the higher-level artifacts that compose
  packages.
- Not a way to seed registry state.
- Not a way to apply security descriptors, which are applied at
  file-creation time (§5.20).

A package whose required behaviour cannot be expressed through the
manifest is incomplete and cannot be installed through the package
format alone. That behaviour MUST be supplied by the higher-level
artifact that composes the package.

---

# 5.25 The Files Manifest

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Per-file integrity beneath the package-level hash — the schema, what it must cover, and the two threats the two levels answer.

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

```json
{
  "schema_version": 1,
  "algorithm": "sha256",
  "entries": [
    { "path": "<string>", "size": <integer>, "hash": "<hex string>" }
  ]
}
```

| 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.

> [!NOTE]
> The correspondence is checked in both directions because each
> direction catches a different failure. A file with no entry is an
> unverifiable file smuggled into the payload; an entry with no file is a
> hash for something that was never shipped, which makes the manifest's
> own count untrustworthy.

Symlinks are integrity-checked through the tar entry's linkname
directly, and directories have no content. The files manifest covers
only what is verifiable by content hash.

## 5.25.3 The package hash

The package hash is the hash of the entire `.peipkg` file in its
compressed on-wire form, computed with the algorithm declared in the
repository index (§5.33). The required algorithm in this version is
SHA-256.

It is recorded in the repository index, to verify that a downloaded file
matches what the repository advertises, and in the signature payload
(§5.28), to bind a signature to that exact file.

The package hash is **not** recorded inside the package: a package
cannot contain its own hash.

## 5.25.4 Algorithm agility

This version supports SHA-256 only. The `algorithm` field here and the
hash identifier in the index reserve syntactic space for future
algorithms. A conforming implementation of this version MUST reject any
algorithm value other than `sha256`.

> [!NOTE]
> BLAKE3 is a likely future addition for performance on large packages.
> The reservation means such a migration can be additive: producers
> continue emitting SHA-256 for compatibility with this version while a
> future version permits BLAKE3 as well.

## 5.25.5 Two levels, two threats

The two levels defend against different things, and a consumer MUST
verify both.

The package hash plus the signature defends against substitution of the
package as a whole. The files manifest defends against corruption or
tampering during extraction, after the signature has been verified.

Verifying only the signature leaves extraction errors and on-disk
corruption undetectable. Verifying only the per-file hashes leaves the
files manifest itself untrusted.

---

# 5.26 Verifying a Package

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The ordered steps a consumer performs before installing anything, why nothing is observable before step three, and how a transaction commits.

A consumer MUST perform the following steps, in this order, before
installing anything from a package:

1. Compute the SHA-256 of the downloaded `.peipkg` file.
2. Compare it against the hash recorded in the repository index (§5.33).
   If they differ, the package is corrupted or substituted; abort.
3. Verify the inline signature (§5.30). If verification fails, the
   package's authenticity is unproven; abort.
4. Decompress and parse the tar archive, enforcing the layout rules of
   §5.12 and the determinism rules of §5.11.
5. Read `.peipkg/manifest.json` and verify it against §5.18.
6. Read `.peipkg/files.json` and verify it against §5.25, including the
   two-way coverage check and the `size_installed` equality of §5.18.
7. For each payload entry, compute its content hash and compare it
   against the files manifest. If any file's hash does not match, abort.
8. Compare the manifest against the index entry that led here (§5.32).
   If any field disagrees, abort.

A consumer MUST NOT install any payload before all eight steps complete
successfully. Partial installation after a verification failure leaves
the system indeterminate and is forbidden.

## 5.26.1 Ordering is logical, not temporal

A consumer MAY compute the hashes for steps 1, 3, and 7 in a single
streaming pass: feeding the compressed bytes simultaneously through a
hasher and a decompressor, piping the decompressed bytes through a
second hasher up to the signature entry, and hashing each file's content
as the tar walk reaches it.

What the ordering requires is that no payload is committed to its final
install path, and no decompressed byte is made visible outside the
consumer's own private state, until every step has completed.

## 5.26.2 Nothing observable before step 3

A consumer MUST NOT make any decompressed payload byte visible to
another process — including through a staging directory reachable from
outside the consumer's own process tree — before signature verification
has succeeded.

Streaming decompression and hashing are permitted. Observable filesystem
effects are not.

## 5.26.3 Verifying the whole transaction first

When a consumer installs several packages together, it MUST complete
steps 1 through 8 for **every** package before extracting **any**
package's payload, and it MUST do so across every installation root the
operation touches.

> [!NOTE]
> This closes a class of multi-package attack: package A is verified,
> extracted, and its contents then influence the verification or
> extraction of package B — A installs a tool B's extraction invokes, or
> A creates a directory whose descriptor decides where B's files land.
> Verifying everything first means extraction operates on a known-good
> set of payloads. The rule binds across roots as well as within one,
> because a package extracted into one root is just as present on the
> filesystem as one extracted into another.

## 5.26.4 Committing a payload

A consumer MUST resolve every path component of an install location
relative to a verified parent-directory file descriptor, without
traversing any symbolic link — including one the consumer itself created
earlier in the same operation. A resolution that would traverse a
symlink MUST abort the operation.

A pre-existing symlink at an install path MUST be removed atomically
before the write, and MUST NOT be followed.

A well-formed package never contains a payload entry whose ancestor
component is a symlink. A resolution failure therefore indicates a
malformed or hostile package, or hostile filesystem state.

On Linux this is achieved with `openat2(..., RESOLVE_NO_SYMLINKS)`
anchored at the relevant permitted top-level destination (§5.14), or
with equivalent semantics using `O_NOFOLLOW` on every component against
a carried directory descriptor. A consumer SHOULD additionally apply
`RESOLVE_BENEATH`, `RESOLVE_NO_XDEV`, and `RESOLVE_NO_MAGICLINKS` as
defence in depth, and SHOULD commit a staged file with `renameat2`
against the same pinned parent descriptor rather than a re-walked path
string.

> [!NOTE]
> The format's symlink-target rules (§5.17) are the first layer of the
> defence and this is the second, and neither is sufficient alone. The
> format forbids a symlink whose target resolves outside the managed
> tree; component-wise resolution ensures that even an in-tree symlink —
> or one already on the filesystem before this package arrived — cannot
> redirect a write. Without the second layer, a package shipping a
> symlink in one transaction and a file *under* that symlink in the next
> silently writes outside where it claims to, with no race required.

---

# 5.27 Decompression Bounds

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The two bounds every consumer enforces against extreme compression ratios, checked continuously, and what happens when one is exceeded.

A consumer MUST bound package decompression, to prevent
resource-exhaustion attacks by packages with extreme compression ratios.

Two bounds apply, and both MUST be enforced.

## 5.27.1 The index-declared bound

The repository index entry's `size_compressed` and `size_installed`
fields (§5.33) bound the legitimate sizes of the compressed and
uncompressed forms. During streaming decompression a consumer MUST
verify that:

- the cumulative compressed bytes consumed do not exceed
  `size_compressed` by more than **the lesser of 1% or 16 MiB**; and
- the cumulative decompressed bytes produced do not exceed
  `size_installed` plus a fixed overhead allowance of **320 MiB**.

Both figures MUST be taken from the **index entry**, not from the
package's own manifest. The manifest lives inside the compressed stream
and is therefore under the control of whoever produced the bytes being
bounded.

> [!NOTE]
> This is the whole point of the bound, and it is easy to get backwards.
> A consumer that reads `size_installed` out of the manifest it is in
> the middle of decompressing has derived its cap from the input it is
> defending against: a hostile package simply declares a large enough
> figure. The index entry is signed by the repository, independently of
> the package, which is what makes it usable as a bound.

The 320 MiB decompressed allowance bounds the structural overhead a
conforming package may legitimately carry above its installed payload:
tar headers and block padding for up to the §5.A limit of 100,000
entries, plus the metadata files at their maximum sizes. A typical
package's overhead is a tiny fraction of it; the allowance is sized so
that a consumer never rejects a package conforming to §5.A.

## 5.27.2 The absolute cap

Independently of any declared size, a consumer MUST abort decompression
when the cumulative decompressed output exceeds an absolute cap. The
default cap is **4 GiB**. A consumer MAY raise it through operator
configuration but MUST NOT raise it silently.

## 5.27.3 Checked continuously

Both bounds MUST be checked on **every chunk** of output, not at
end-of-stream.

> [!NOTE]
> Constructed Zstandard payloads can achieve compression ratios beyond
> 10,000:1, so a 10 MB package can decompress to terabytes. A check
> deferred to end-of-stream never runs.

## 5.27.4 On exceeding a bound

Exceeding either bound MUST cause the package to be rejected with no
further processing and nothing committed to disk.

## 5.27.5 Cross-checking the declared size

The manifest's `size_installed` and the index entry's `size_installed`
MUST be equal, and a consumer MUST verify that equality (§5.32).
Together with the files-manifest sum required by §5.18, this makes the
figure a quantity all three of the producer, the repository, and the
consumer can compute independently and agree on.

---

# 5.28 Package Signatures

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> What a signature binds, which bytes are signed, the envelope carrying it, and how an unsigned package is treated.

A package signature binds a package's bytes to a signing key. Verifying
it establishes that the package has not been altered since signing, and
that the signer held the trusted private key.

## 5.28.1 The signature entry

The signature is the final entry in the tar archive, at
`.peipkg/signature` (§5.12). Its content is a UTF-8 JSON document. Every
tar attribute of the entry — mode, owner, mtime, magic — follows the
determinism rules of §5.11 unmodified, so the entry is mode `0777` like
every other, and §5.16's rationale applies to it identically.

## 5.28.2 The signed bytes

The signature is over a SHA-256 hash computed across the concatenation
of every complete tar entry block — header, content, and content-block
padding to the next 512-byte boundary — for every entry **preceding**
`.peipkg/signature`, in archive order.

The signed bytes do **not** include:

- the tar entry header or content of `.peipkg/signature` itself;
- the two trailing zero blocks that terminate a tar archive;
- any compression artifact — signing operates on the uncompressed tar
  bytes.

> [!NOTE]
> Compressing the signed tar produces the on-wire file. Compression is
> independent of signing: the same signed tar compressed at different
> levels verifies identically once decompressed. This is what makes
> §5.10's inability to fix compression parameters harmless for
> authenticity.

## 5.28.3 The envelope

```json
{
  "schema_version": 1,
  "algorithm": "ed25519",
  "key_fingerprint": "<hex string>",
  "signature": "<base64 string>"
}
```

| 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.

> [!NOTE]
> The error must name the version, not the field. An implementation that
> reports "unknown field `x`" when the real condition is "this envelope
> is from a newer specification version" sends the operator looking for
> a malformed package instead of an outdated tool.

## 5.28.5 Signing procedure

To sign a package, a producer:

1. Constructs every tar entry except `.peipkg/signature`.
2. Serialises them as an uncompressed tar byte stream in archive order.
3. Computes the SHA-256 of that stream.
4. Signs the resulting 32-byte hash with its Ed25519 private key, per
   RFC 8032.
5. Constructs the envelope with the signature value and key fingerprint.
6. Appends the `.peipkg/signature` entry — header, JSON content, and
   padding — to the tar byte stream.
7. Compresses the complete stream to produce the `.peipkg` file.

Note that the Ed25519 message is the 32-byte SHA-256 digest, not the tar
stream itself. A verifier MUST do the same.

> [!NOTE]
> The digest indirection is deliberate and applies to every signature in
> this chapter, package and detached alike: one signature construction
> covers a small JSON index and a multi-gigabyte package, and the large
> case is verifiable in a single streaming pass without buffering.

## 5.28.6 Determinism

Given identical signed bytes and an identical key, the Ed25519 signature
is deterministic per RFC 8032 §5.1.6. A producer that builds the same
tar archive and signs it with the same key MUST produce a byte-identical
signature entry.

## 5.28.7 Unsigned packages

A package without a `.peipkg/signature` entry is **unsigned**.

The format permits unsigned packages. A consumer MAY install one if the
originating repository's trust policy permits it (§5.37).

An unsigned package MUST conform to every other requirement of this
chapter. The manifest, the files manifest, the payload rules, and the
integrity rules apply identically to signed and unsigned packages.

> [!NOTE]
> The official repository requires signatures. Other repositories may
> permit unsigned packages for development, homelab, or air-gapped use.
> The format is permissive; the policy is enforced by the consumer,
> per repository.

---

# 5.29 Keys and Fingerprints

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Ed25519 keys — their encoding, how a fingerprint is computed, the key roles, and what makes up a repository's trust set.

## 5.29.1 Algorithm

Signatures use Ed25519 as defined in RFC 8032. A conforming
implementation MUST support Ed25519 signing and verification. Other
algorithms are reserved for future versions.

> [!NOTE]
> Ed25519 was chosen for a small public key (32 bytes) and signature (64
> bytes), deterministic output with no nonce-reuse risk, fast
> verification, resistance to most side channels, and broad library
> support.

## 5.29.2 Public key encoding

A public key is the raw 32-byte Ed25519 public key value of RFC 8032
§5.1.5.

When published as a file, a public key MUST be encoded either as the raw
32 bytes, or as a PEM `PUBLIC KEY` block per RFC 7468 in the
SubjectPublicKeyInfo form. Tooling MUST accept both.

A published public key file MUST contain only the public key, in one of
those two encodings.

## 5.29.3 Fingerprint

A public key's fingerprint is the lowercase hexadecimal SHA-256 of the
**raw 32-byte public key**:

```
fingerprint = lowercase_hex(sha256(public_key_bytes))
```

The fingerprint is 64 hexadecimal characters. It is computed over the
raw key bytes and never over a PEM or SubjectPublicKeyInfo encoding of
them.

The fingerprint is the canonical identifier of a public key throughout
this chapter: the signature envelope's `key_fingerprint` (§5.28) and the
repository descriptor's signing key declarations (§5.31) both use this
form.

A consumer that fetches a public key MUST verify the key's fingerprint
against the fingerprint that identified it before admitting it to a
trust set.

> [!NOTE]
> Fingerprints SHOULD be displayed to people in a form that makes a
> mismatch visually obvious — conventionally four-character groups
> separated by spaces or colons. The normative on-wire form remains the
> unbroken 64-character lowercase hex string.

## 5.29.4 Key roles

Two roles are distinguished by usage, not by structure:

- **Signing keys** are used by a producer to sign packages, descriptors,
  and indexes.
- **Trusted keys** are configured into a consumer as keys whose
  signatures it accepts.

A single key MAY play both roles.

## 5.29.5 The trust set

A consumer maintains a **trust set**: the public keys whose signatures it
accepts.

The trust set MUST be partitioned per repository. Each configured
repository contributes its declared signing keys to the trust set,
scoped to that repository's content.

A signature MUST be accepted only if its `key_fingerprint` matches a key
in the trust set **scoped to the repository the content was fetched
from**.

> [!NOTE]
> Cross-repository acceptance is forbidden. A package fetched from
> repository R and signed by a key trusted only for repository S MUST be
> rejected, even though both keys are in the consumer's overall
> configuration. Without this, a compromised low-trust repository could
> serve packages whose signatures verify against a high-trust key, which
> is an escalation from one context to the other.

## 5.29.6 Private keys

Private key material is not the concern of this specification. Its
generation, storage, custody, and rotation are operational matters for
the key holder.

Hardware security modules, threshold signing schemes, and air-gapped
signing are all compatible with this format, so long as the resulting
signature conforms to the envelope of §5.28. This specification cares
about the bytes, not how they were produced.

---

# 5.30 Verifying a Signature

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The streaming verification procedure, how the end of the signed range is located, and every condition that fails it.

To verify a package's signature, a consumer MUST:

1. Decompress the `.peipkg` file to the uncompressed tar bytes.
2. Walk the tar archive in order, accumulating each entry's complete
   blocks — header, content, and content-block padding — until reaching
   the entry at path `.peipkg/signature`.
3. Stop at that entry. What has been accumulated is the signed byte
   range of §5.28.
4. Parse the content of `.peipkg/signature` as the signature envelope.
5. Validate the envelope's `schema_version` and `algorithm`. Reject if
   either is unrecognised, naming the version mismatch where that is the
   cause.
6. Look up the public key by `key_fingerprint` in the trust set scoped
   to the originating repository (§5.29). If no matching key is in that
   trust set, reject.
7. Determine whether the key is usable for verification given its status
   (§5.32). Reject a revoked key, and a transitioning key past its
   validity, **before** performing any cryptographic operation.
8. Compute the SHA-256 of the signed bytes.
9. Verify the signature against that hash with the looked-up key, per
   RFC 8032.

If step 9 succeeds the signature is valid. If it fails, reject the
package.

> [!NOTE]
> Step 7 before step 9 is deliberate. A revoked key's signatures are
> rejected *regardless of cryptographic validity*, so checking status
> first means a revoked key can never produce a "signature verified"
> result anywhere in the implementation, even transiently.

## 5.30.1 Streaming

The accumulation in step 2 is conceptual. An implementation MAY hash the
signed bytes incrementally as it walks, without retaining the stream;
steps 8 and 9 then operate on the running hash state.

Streaming MUST NOT be conflated with early commitment. A consumer
hashing incrementally MUST still defer every externally observable
filesystem effect until step 9 has succeeded (§5.26).

## 5.30.2 Locating the end of the signed range

A verifier computing the signed range by subtracting a fixed header size
from a stream offset MUST account for an extended header block preceding
the signature entry. §5.11 makes such a header unnecessary for a
short-named entry, but a verifier that assumes it away will
mis-locate the range for any archive that carries one, and report a
signature failure for what is really a framing difference.

## 5.30.3 Failure conditions

A consumer MUST reject a package as unverified when any of these holds:

1. The package contains no `.peipkg/signature` entry **and** the trust
   policy for its originating repository requires signed packages.
2. The `.peipkg/signature` entry is not the last named entry in the
   archive.
3. The envelope does not parse, or carries an unknown or duplicate
   field.
4. The envelope's `schema_version` is not 1.
5. The envelope's `algorithm` is not recognised.
6. The envelope's `key_fingerprint` matches no key in the trust set
   scoped to the originating repository.
7. The matching key's status does not permit verification.
8. The cryptographic verification fails.

A rejected package MUST NOT be installed, and the consumer MUST report
which condition triggered the rejection.

> [!NOTE]
> Condition 2 is not redundant with §5.12's ordering rule; it is the
> same rule stated where its consequence is visible. Everything after
> the signature entry is unsigned, so a consumer that tolerates a
> trailing entry has accepted attacker-chosen bytes inside a package
> whose signature verifies.

## 5.30.4 A package with no originating repository

A consumer MAY accept a package supplied directly rather than fetched
from a configured repository — a file handed to it on the command line.
Such a package has no originating repository, and therefore no trust set
to verify against.

A consumer that accepts one MUST treat it as unverified: it MUST NOT
report the package as signature-verified, and it MUST surface to the
operator that the package's authenticity was not established.

## 5.30.5 What verification proves

A verified signature establishes **integrity** — the archive bytes
preceding the signature entry have not been altered since signing — and
**authenticity** — the signer held the private key corresponding to a
trusted public key at the time of signing.

It does not establish that the signed bytes encode meaningful content: a
consumer MUST still validate the manifest, the files manifest, and the
per-file integrity (§5.26). It does not establish that the signer
intended the package for any particular system. And it does not
establish that the content is free of bugs or malice. Signing certifies
provenance, not safety.

## 5.30.6 Replay and substitution

Signature verification alone does not prevent replay — an attacker
substituting an older, validly signed package for a newer one. Defence
against substitution comes from the repository index (§5.33), which is
itself signed, declares the current authoritative version of each
package, and records each package's hash.

A consumer MUST consult the index and verify the package's hash against
it before accepting the package, even when the signature verifies.

> [!NOTE]
> An old signed package whose signature still validates against a
> still-trusted key is otherwise indistinguishable from the current one.
> The index's per-package hash binds "what is current" to "this exact
> file", which is what closes the gap.

---

# 5.31 The Repository Descriptor

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The small JSON document at a well-known URL that is a repository's entry point — its schema, signing keys, index pointers and canonical form.

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

```json
{
  "schema_version": 1,
  "repo": {
    "name": "<string>",
    "description": "<string>",
    "signing": { "algorithm": "<string>", "keys": [<key>...] }
  },
  "indexes": {
    "active":  { "url": "<string>", "signature_url": "<string>" },
    "archive": { "url": "<string>", "signature_url": "<string>" }
  }
}
```

| 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

```json
{
  "algorithm": "ed25519",
  "keys": [
    { "fingerprint": "<hex>", "url": "<string>", "status": "active" }
  ]
}
```

| 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`.

> [!NOTE]
> The descriptor signature defends against an attacker who can serve
> content from `<repo-base>` substituting alternate signing keys.
> Without it, whoever can substitute the descriptor can substitute the
> keys, and from there re-sign the indexes and the packages. The
> chicken-and-egg at first add is broken by the operator supplying an
> expected fingerprint out of band (§5.37).

A repository configured to permit unsigned content MAY publish an
unsigned descriptor and unsigned indexes. This is a security weakening
opted into per repository, and a consumer MUST NOT treat the absence of
a signature as a fetch failure for such a repository.

## 5.31.6 Canonical form

The descriptor SHOULD be canonically formatted so that signing is
reproducible: fields in the schema's order, key arrays sorted as
specified, no trailing whitespace, a single trailing newline.

## 5.31.7 Naming

A consumer MAY refer to a repository by a local handle of its own
choosing. When it does, it MUST NOT require that handle to equal
`repo.name`, and MUST NOT compare an index's `repo` field against the
local handle. An index's `repo` field is compared against the
descriptor's `repo.name`.

---

# 5.32 Signing Key Status and Rotation

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> What each key status means for verification, how rotation works, the offline emergency key, and what happens on compromise.

## 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 its `valid_until` timestamp, but no longer produces
  new signatures. A consumer MUST accept its signatures while the
  current time is at or before `valid_until`, and MUST reject them
  afterwards. A `transitioning` key entry MUST carry a `valid_until`.
- **`revoked`** — the key is no longer trusted under any circumstance. A
  consumer MUST reject its signatures regardless of when they were
  produced and regardless of whether they verify cryptographically.

A status other than these three is invalid.

A repository MAY have several `active` keys, permitting parallel
signing; any number of `transitioning` keys, each with its own
`valid_until`; and any number of `revoked` keys.

`revoked` is the explicit signal of a compromise event. `transitioning`
is for routine rotation only, and the two MUST NOT be conflated.

## 5.32.2 Retention of revoked entries

A revoked entry MUST be retained in the descriptor for at least one year
after the revocation. Removing it prematurely would hide the public
acknowledgement of compromise from consumers with stale caches.

A repository MUST continue to serve the public key file of a revoked key
for as long as its entry is retained, so that a consumer fetching the
descriptor can resolve every key it declares.

> [!NOTE]
> Keeping a revoked key in the descriptor is useful even though its
> signatures are rejected regardless: it is a public acknowledgement a
> consumer can audit, and it means a consumer encountering a signature
> from that key gets "this key was revoked" rather than a generic "key
> not in trust set".

## 5.32.3 Rotation

A repository rotates a signing key by:

1. Generating a new key pair.
2. Adding the new public key to the descriptor's key list alongside the
   existing one.
3. Beginning to sign new content with the new key.
4. After a transition period during which both are advertised, marking
   the old key `transitioning` with a `valid_until`, and eventually
   removing it.

During the transition, content signed with either key is acceptable.
After the old key's validity lapses, only the new key's signatures
remain acceptable.

The length of the transition period is operational policy and is not
specified here. Its purpose is to give consumers time to fetch the
updated descriptor and learn the new key before old signatures stop
being honoured.

## 5.32.4 The offline emergency key

A repository SHOULD maintain at least one **offline** active signing key
in addition to its routine signing keys. The offline key's private
material is stored separately from build infrastructure and is used only
for descriptor updates and emergency rotations.

The offline key exists to break a chicken-and-egg in compromise
response. Revoking a compromised signing key requires publishing a new
descriptor, which must itself be signed. If the only trusted key is the
compromised one, the operator must sign the revocation with the
compromised key — giving an attacker who holds that same key the ability
to substitute their own revocation that adds a key of their choosing.

With an offline key, the operator signs the descriptor update revoking
the compromised key without relying on the compromised key at all.
Consumers holding the offline key in their trust set accept the update;
consumers who do not must perform an out-of-band trust-anchor refresh
(§5.37).

> [!NOTE]
> This is SHOULD rather than MUST because the implementation question —
> hardware module, air-gapped machine, threshold custody — is the
> operator's. A future version may make it mandatory.

## 5.32.5 Compromise

A key SHOULD be considered compromised if its private material may have
been obtained by an unauthorised party.

A compromised key MUST be marked `revoked` in the descriptor immediately
on discovery. Packages signed with it SHOULD be re-signed with a fresh
key and re-published at new revisions.

The `revoked` status is the in-band revocation channel this
specification defines. It defends at descriptor-update granularity: a
consumer that successfully refreshes learns of the revocation at once,
and a consumer caching an older descriptor retains trust in the revoked
key only until it re-syncs — a window bounded by the maximum trusted age
of §5.37.

> [!NOTE]
> A richer out-of-band mechanism — signed revocation lists, a key
> transparency log — is reserved for a future version. The combination
> of `revoked` status, a bounded maximum trusted age, and signed
> descriptor updates closes the practical compromise-response window
> without requiring separate revocation infrastructure.
>
> The corollary a consumer must not miss: **revocation only protects the
> paths that consult the trust set.** Any code path that installs a
> package without resolving its key against the repository's trust set —
> a build or image-composition path, say — is a path on which revocation
> has no effect at all.

---

# 5.33 The Active Index

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> The index listing the current version of every advertised package — its schema, entries, derivation rule, and deliberate omissions.

The active index lists the current version of every package a repository
advertises. It is the index a consumer fetches on a routine sync.

## 5.33.1 Location and signing

The active index's URL and its detached signature's URL are declared by
the descriptor (§5.31).

The index MUST be accompanied by a detached signature: a signature
envelope (§5.28) over the SHA-256 digest of the index file's exact
bytes. The signing key MUST be one of the descriptor's keys with status
`active` or `transitioning`.

A repository configured to permit unsigned content MAY publish the
active index unsigned.

> [!NOTE]
> Detached metadata signatures and package signatures share a single
> construction — an envelope over a SHA-256 digest — so a verifier
> implements it once. The digest indirection is free for a small JSON
> index and lets a multi-gigabyte package be verified in one streaming
> pass.

## 5.33.2 Schema

```json
{
  "schema_version": 1,
  "repo": "<string>",
  "kind": "active",
  "index_version": <integer>,
  "generated_at": "<RFC 3339 timestamp>",
  "packages": [<package_entry>...]
}
```

| 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

```json
{
  "name": "<string>",
  "version": "<string>",
  "architecture": "<string>",
  "description": "<string>",
  "license": "<string>",
  "homepage": "<string>",
  "default_root": "<root reference>",
  "dependencies": [<dependency>...],
  "optional_dependencies": [<dependency>...],
  "conflicts": [<dependency>...],
  "provides": [<provides>...],
  "replaces": [<replaces>...],
  "side_effects": [<string>...],
  "size_compressed": <integer>,
  "size_installed": <integer>,
  "hash": { "algorithm": "<string>", "value": "<hex string>" },
  "url": "<string>",
  "build": { "timestamp": "<RFC 3339>", "farm_id": "<string>" }
}
```

An entry MUST contain `name`, `version`, `architecture`,
`dependencies`, `conflicts`, `provides`, `replaces`, `side_effects`,
`size_compressed`, `size_installed`, `hash`, and `url`. The array fields
MUST be present even when empty, emitted as `[]`. The remaining fields
are RECOMMENDED and MAY be omitted.

`name`, `version`, and `architecture` MUST each be validated against
§5.3, §5.5, and §5.8 respectively on parse — with the same strictness a
manifest receives. An index is fetched from the network and its values
flow into URL construction and into the consumer's own records.

`size_compressed` and `size_installed` are required because they are the
input to the decompression bound of §5.27.

`hash` carries `algorithm`, which MUST be `sha256` in this version, and
`value`, the lowercase hexadecimal SHA-256 of the `.peipkg` file in its
compressed on-wire form.

## 5.33.4 The derivation rule

The active index is a **derived view** of the packages it advertises.
Every field of an entry MUST exactly match the corresponding field of
that package's manifest where one exists, and MUST exactly match the
properties of the actual package file for `hash`, `size_compressed`, and
`url`.

Tooling generating an index MUST extract values directly from package
manifests. Editing an index by hand is forbidden.

Where a manifest contradicts an index entry, the manifest is
authoritative (§5.18) — and the contradiction is a defect in the
repository, not a difference to accommodate. A consumer MUST compare the
downloaded package's manifest against the index entry that led to it,
across **every** field the index carries, and MUST reject the package on
any mismatch (§5.26 step 8).

> [!NOTE]
> Comparing only name, version, and architecture is not enough, and the
> gap is not theoretical. A consumer builds its entire dependency and
> conflict graph from index claims. A repository that publishes an entry
> declaring no `conflicts` for a package whose manifest declares one
> against a critical installed package gets a plan computed on the lie,
> approved by the operator on that basis, and applied — with the real
> relations discovered by nobody. The index is a convenience for
> planning without downloading; it is not a second source of truth.

## 5.33.5 Deliberate omissions

The index omits three manifest fields:

- `sd_overrides` — not relevant to planning, and potentially large.
- `build.source_ref` — long and low in information density; consult the
  package when it is wanted.
- the manifest's own `schema_version` — the index carries its own.

These remain in the manifest and are available to a consumer that
fetches the package. Because they are omitted rather than mismatched,
they are outside the comparison above.

## 5.33.6 URLs

`url` declares where the package file is fetched from, and MAY be
relative or absolute (§5.36). The conventional form is relative:

```
"url": "/p/nginx/1.26.2-3/nginx_1.26.2-3_x86_64.peipkg"
```

This keeps an index portable: the same file is valid at any
`<repo-base>` hosting the same package files.

## 5.33.7 Ordering

The `packages` array MUST be sorted lexicographically by `name`. Two
entries with the same `name` in an active index are invalid: each name
appears exactly once.

> [!NOTE]
> Per-name uniqueness is what makes the index "active" — one current
> version of each package. The archive index (§5.35) relaxes exactly
> this constraint and nothing else.

## 5.33.8 Unknown fields

A consumer MUST ignore unknown fields, at the top level and per package,
per §5.9. A producer MAY emit additional fields in a future schema
version.

The exception is a field whose meaning is critical to correctness, such
as a hash algorithm identifier. Such changes are expected to arrive
through a `schema_version` bump, not as a silent addition.

## 5.33.9 Size and caching

For a repository of a few hundred packages the active index is on the
order of 100 KB compressed. A consumer SHOULD fetch with HTTP-level
compression where it is offered, and SHOULD cache the parsed index
between invocations: the index changes only when the repository
publishes, which is far less often than a consumer reads.

A cached index MUST be stored under a security descriptor granting write
access only to the principal permitted to install packages.

A consumer MUST re-verify a cached index's signature on **every**
operation that relies on it, rather than trusting its cached state
across operations. Caching avoids re-parsing; it does not avoid
re-verifying.

A consumer SHOULD additionally cross-check a cached index against its
own recorded freshness state (§5.34), and reject a cached index whose
`index_version` or `generated_at` disagrees with what it recorded.

> [!NOTE]
> Without re-verification, whoever can write to the cache can substitute
> metadata between the cache write and the next read. Re-verifying on
> every use closes that race, and the cost is negligible: verifying a
> signature over a few hundred kilobytes is sub-millisecond. The
> cross-check against recorded state closes the matching hole, where an
> attacker substitutes an older *validly signed* index directly into the
> cache, bypassing the refresh path where §5.34's floor is enforced.

---

# 5.34 Freshness and Rollback Protection

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> An index that verifies is not necessarily current — monotonic versions, staleness limits, and the defences against rollback and freeze.

An index that verifies is not necessarily current. This section defends
against **rollback** — replaying an older signed index to hide newer
packages — and **freeze** — holding a consumer at a current-but-stale
index while its clock runs on.

Every requirement here applies to **both** the active index and the
archive index. An attacker who can replay one can replay the other, and
the archive index is the candidate source for every downgrade and pin.

## 5.34.1 Monotonic index versions

Each publication of an index MUST set `index_version` to a value
strictly greater than any previously published value for the same
repository.

A consumer MUST record, per repository, the highest `index_version` it
has ever observed. On each fetch it MUST reject an index whose
`index_version` is less than that recorded value, **even when the index
is correctly signed by a still-trusted key**.

A consumer MUST also record the `generated_at` of the last index it
trusted, and MUST reject an index whose `generated_at` is older than the
recorded value.

## 5.34.2 No progress is a failed fetch

A fetch returning an index whose `index_version` **and** `generated_at`
both equal the recorded values is a **failed** refresh, not a successful
one. A consumer MUST NOT advance its "last successful refresh" timestamp
on such a fetch.

> [!NOTE]
> This is the anti-freeze rule, and it is the one most often skipped.
> Without it, an attacker who can serve the same signed index
> indefinitely keeps every consumer's refresh timestamp advancing while
> the content never changes, so the maximum-trusted-age check of §5.37
> never fires and the consumer never notices it has been pinned.

## 5.34.3 The initial floor

Adding a repository bootstraps the consumer's recorded floor. To defend
against an attacker serving a stale-but-signed index at that moment, a
repository SHOULD distribute a **minimum acceptable `index_version`**
alongside its trust anchors, through the same out-of-band channel. A
consumer SHOULD use that minimum as its initial floor, and MUST refuse
the add when the first index fetched falls below it.

A consumer MUST NOT reset a recorded floor as a side effect of any
operation other than removing the repository. In particular, re-adding
an already-configured repository MUST NOT lower the floor: the operation
either applies the recorded floor as a refresh would, or is refused.

> [!NOTE]
> Repository-add reads as idempotent, and configuration-management
> convergence loops treat it that way. If adding an already-known
> repository rewrites the floor unconditionally, a rollback that the
> refresh path correctly refuses becomes permanent and invisible the
> next time that loop runs. Removing the repository first is the
> sanctioned reset (§5.37), because it discards the trust state too.

## 5.34.4 Maximum index staleness

A consumer MUST enforce a maximum staleness window on the index itself,
measured from its `generated_at`. An index older than **90 days** MUST
trigger a refresh attempt before any install operation proceeds.

The 90-day default MAY be tuned by operator configuration; a value
greater than 365 days SHOULD generate a warning each time it is
exercised.

> [!NOTE]
> This is a different measurement from the maximum trusted age of §5.37,
> and both are needed. Trusted age asks "how long since I successfully
> refreshed"; index staleness asks "how old is the metadata I am acting
> on". A repository that bumps `index_version` on every publication
> while stamping an ancient `generated_at` satisfies the first check
> forever and fails the second immediately.

## 5.34.5 What these checks buy

Per-package signing and index signing both still verify under a
rollback: the attacker is replaying genuine, correctly signed content.
What changes is the *set* of packages the consumer believes is current.
The monotonic version check is what makes that set unable to move
backwards, and the no-progress rule is what stops it from being frozen
in place.

---

# 5.35 The Archive Index

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Every version a repository has ever advertised, its retention and schema, and how it relates to the active index.

The archive index lists every version of every package a repository has
ever advertised, including versions superseded by newer releases. It is
the source of historical data for downgrade, version pinning, and
forensic queries.

## 5.35.1 Retention

A repository MUST retain every package version it has ever advertised.
Once a package has been published at version V, the repository MUST
continue to make V fetchable indefinitely, and the archive index MUST
continue to list it.

> [!NOTE]
> This is a deliberate departure from rolling-only models. Retention is
> what supports rollback, reproducible deployment, security forensics,
> and long-running systems held at an older version.

A repository MAY retire pre-release or development versions under a
stated retention policy. Retirement MUST NOT silently remove a package a
consumer might be using, and SHOULD be coordinated with consumer notice.

A pruned package MUST also be removed from the repository's package
storage: the archive index MUST NOT reference a package file that is no
longer fetchable.

> [!NOTE]
> Reasonable policies include retaining all stable releases
> indefinitely, retaining pre-releases for a year after each successor,
> or never pruning anything. This specification neither mandates nor
> forbids pruning.

## 5.35.2 Location and signing

The archive index's URL and its detached signature's URL are declared by
the descriptor (§5.31). It MUST be signed under the same rules as the
active index (§5.33).

## 5.35.3 Schema

The top-level schema is identical to the active index (§5.33), except:

- `kind` MUST be `archive`;
- the `packages` array MAY contain several entries with the same `name`,
  at different versions.

The per-package entry schema is identical. Each historical version
contributes one entry.

`index_version` semantics are identical, and every freshness and
rollback requirement of §5.34 applies to the archive index exactly as it
does to the active one.

## 5.35.4 Ordering

The `packages` array MUST be sorted lexicographically first by `name`,
then within a name by `version` **descending** per §5.6. The first entry
for any name is its highest version; subsequent entries for that name
are progressively older.

Where two entries of one name share a version — differing only in
architecture — the ordering between them MUST be total and MUST be
stated by the producer's tooling, so that the file is reproducible.

> [!NOTE]
> Highest-first ordering puts the most recently shipped version of each
> package at the top of its name group, so a consumer scanning for the
> latest version satisfying a constraint can stop as soon as the
> constraint is satisfied or exceeded.

## 5.35.5 Relationship to the active index

For every entry in the active index there MUST be at least one entry in
the archive index with the same `name`, `version`, `architecture`, and
`hash`. The archive index is a superset of the active index.

Equivalently: the active index is the per-name maximum projection of the
archive index, where "maximum" is the highest version per name under
§5.6.

A repository publishing both indexes SHOULD publish them at the same
`index_version` and `generated_at`, so that a consumer holding one has a
usable floor for the other.

## 5.35.6 Fetch frequency

The archive index is large compared to the active index — potentially
many megabytes for a long-running repository. A consumer SHOULD fetch it
only when it is needed: for a historical query, for a pin or a
downgrade, or when its cached copy expires. A routine sync SHOULD fetch
only the active index.

A consumer SHOULD cache the archive index aggressively, since it changes
only when a version is published or pruned.

---

# 5.36 URL Conventions

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Every URL maps to a static file — the repository base, the conventional paths, sibling artifacts, hosting and network failure.

Every URL in this chapter maps to a static file. The protocol requires
no server-side computation, no dynamic response, and no content
negotiation beyond optional HTTP-level compression.

## 5.36.1 The repository base

A repository is identified by a base URL, `<repo-base>`.

The base URL MUST be a syntactically valid HTTP or HTTPS URL per
RFC 3986, and MUST NOT have a trailing slash: the well-known relative
paths below are appended directly.

HTTPS MUST be used, unless the consumer has been configured with an
explicit **per-repository** insecure-transport allowance. There is no
global form of that allowance, and its use MUST generate a per-operation
warning.

Enabling the allowance on a repository that has already been added MUST
require explicit operator authorisation and MUST emit an audit event.
Setting it as part of the initial add is covered by the operator's trust
decision at that moment and requires no separate event beyond the add's
own.

> [!NOTE]
> Insecure transport is intended for a trusted local network during
> development. Relying on package signing alone for transport integrity
> leaves the consumer exposed to traffic analysis and to
> metadata-substitution attacks even when content verification succeeds.

A consumer MAY additionally support a `file://` base URL for local
development. A `file://` repository MUST be subject to the same
per-repository allowance as an HTTP one: it is not HTTPS, and admitting
it silently makes removable or network-mounted media a trusted source
without the operator ever acknowledging it.

## 5.36.2 Conventional paths

| 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.

> [!NOTE]
> Relative URLs are RECOMMENDED: they keep an index portable, valid
> under any `<repo-base>` hosting the same layout. An absolute URL pins
> the index to a host and requires regeneration when the host changes.

## 5.36.6 Hosting

A conformant repository may be hosted on a plain HTTP server, an object
store with an HTTP frontend, a static site host, a CDN in front of any
of those, or a combination — descriptor and indexes on a static host,
package files on object storage behind redirects.

## 5.36.7 Network failure

A consumer that fails to fetch a URL MUST NOT silently fall back to
outdated cached data. Using a stale cache without explicit operator
consent can mask substituted content or a revoked-key update.

A consumer SHOULD offer a way to configure cache-staleness tolerance per
repository.

A consumer whose cached index for a configured repository fails to load
or verify MUST treat that as a failure of the operation rather than
proceeding without that repository.

> [!NOTE]
> Silently continuing is worse than it looks. Dropping a repository from
> consideration mid-operation does not merely lose candidates: it can
> silently promote a lower-priority repository's package into a role the
> dropped one was filling, which is an escalation dressed as a warning.

---

# 5.37 Establishing Trust in a Repository

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Trust is configured per repository and never globally — adding one, guarding a mistyped fingerprint, signature policy and refresh.

Trust is configured **per repository**, never globally. Each repository a
consumer is configured with has its own trusted signing keys (§5.29),
its own signature policy, and its own priority.

## 5.37.1 Adding a repository

To add a repository, the operator supplies its `<repo-base>` URL, one or
more expected key fingerprints — the **trust anchors** — and a signature
policy.

The consumer then:

1. Fetches `<repo-base>/repo.json` and `<repo-base>/repo.json.sig`.
2. Fetches the public key for each supplied anchor fingerprint, from the
   conventional URL or from the URL the descriptor declares, and
   verifies each fetched key against the fingerprint that named it
   (§5.29).
3. Verifies the descriptor's signature against those anchor keys **and
   only those**.
4. On success, records the descriptor's contents — including every
   signing key and status — as the repository's initial trust state.
5. On failure, rejects the add and reports why.

A consumer MUST NOT add a repository whose signing key it learned from
the repository itself without prior verification against an anchor.
Trust anchors are obtained out of band: through a project's website,
its documentation, or the operating system image.

> [!NOTE]
> Step 2 necessarily issues requests derived from an unverified
> document, since the descriptor is what names the key URLs. A consumer
> SHOULD limit those requests to the keys matching the supplied anchors,
> and MUST NOT follow a key URL for a fingerprint the operator did not
> name — otherwise a substituted descriptor can direct the consumer to
> fetch from arbitrary hosts before anything has been verified.

## 5.37.2 Guarding against a mistyped fingerprint

When presenting a fetched key for confirmation, a consumer MUST:

- display the 64-character fingerprint in groups separated by spaces or
  colons — conventionally four characters per group, as
  `1a2b 3c4d 5e6f ...`;
- display the fetched key's fingerprint **alongside** the one the
  operator supplied, for visual comparison, before recording any trust
  state;
- require explicit confirmation before recording. Automatic confirmation
  on the basis of a bit-for-bit match is permitted only in a
  non-interactive context where the operator pre-supplied the
  fingerprint through a configured channel.

When a repository declares several `active` keys, the operator is
RECOMMENDED to supply anchors for at least two of them, as
defence-in-depth against a single mistyped anchor.

A consumer MUST report an anchor mismatch by naming both the anchor the
operator supplied and the fingerprints the descriptor actually declares.
A mismatch is most often a transcription error, and that is precisely
the diagnostic needed to find one.

## 5.37.3 Signature policy

| 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.

> [!NOTE]
> The two failure modes here are mirror images and both are real. A
> consumer that demands a signature file under `optional` cannot add a
> repository that §5.31 explicitly permits to publish none. A consumer
> that stops verifying entirely because no anchors were configured
> leaves a repository fully substitutable by anyone on the network path,
> permanently, even after it starts publishing good signatures.

## 5.37.4 Refresh

A consumer SHOULD refresh its cached repository state periodically. A
refresh MUST:

1. Fetch the current descriptor and its signature.
2. Verify the signature against any key whose status was `active` or
   `transitioning` in the **previously trusted** descriptor.
3. On success, record the new descriptor as the current trust state,
   replacing the previous key set with the new one.
4. Fetch the active index and verify it against the new descriptor's
   keys, applying §5.34.
5. Optionally fetch and verify the archive index, applying §5.34 to it
   as well.

A failed refresh MUST leave the previous trust state in place and be
reported. A consumer MUST NOT fall back to unverified state.

> [!NOTE]
> A failed refresh may mean the repository is unavailable, the network
> is interrupted, or the signing key was rotated to one the previously
> trusted set does not contain. These are distinct operational concerns;
> the consumer surfaces the failure and lets the operator distinguish
> them.

## 5.37.5 Maximum trusted age

A consumer MUST track the time of the last successful refresh per
repository. When that exceeds the **maximum trusted age**, the consumer
MUST attempt a refresh before any install, upgrade, or downgrade against
that repository. If the attempt fails, the consumer MUST report the
failure and refuse the operation, unless the operator explicitly
authorises proceeding on stale trust state.

The default maximum trusted age is **30 days**. It MAY be tuned by
operator configuration; a value above 180 days SHOULD produce a
per-operation warning, so that a configuration effectively disabling the
check stays visible.

> [!NOTE]
> The maximum trusted age bounds the window in which a
> compromised-but-not-yet-revoked key can be used against a consumer
> that has not refreshed. Without it, a long-offline consumer trusts a
> rotated key indefinitely.

## 5.37.6 Priority

A consumer MAY configure several repositories. Each has a numeric
priority: a positive integer, where a **lower number is a higher
priority**.

A consumer's default assignment SHOULD give the official repository the
lowest number. Other repositories receive priorities at the operator's
discretion.

## 5.37.7 Removal

A consumer MAY remove a configured repository at any time. Removal
deletes the cached state and the trust set scoped to that repository. It
does **not** uninstall packages already installed from it; those remain
installed, and their origin is retained.

Re-adding a removed repository performs the full trust ceremony afresh;
previous state is not implicitly restored.

## 5.37.8 Orphaned packages

A package whose originating repository has been removed or revoked is
**orphaned**: its trust chain is no longer verifiable by the current
trust state. A consumer MUST:

- display an orphaned package with a clear indicator in query output;
- surface the orphan state on any operation involving it, and recommend
  an audit before proceeding;
- refuse an upgrade to an orphaned package unless a currently trusted
  repository now claims it by name.

A consumer MUST NOT treat an unknown origin as an *absent* origin.
Wherever this chapter gates an operation on the relative priority of two
repositories, an orphaned package's origin MUST be treated as at least
as trusted as any configured repository, so that the gate still fires.

> [!NOTE]
> The failure this prevents is subtle and severe: a repository removed
> *because its keys were stolen* leaves packages behind whose origin no
> longer resolves. If an unresolvable origin is quietly treated as
> lowest-priority, every cross-repository guard below stops firing for
> exactly those packages — so revoking a repository would *lower* the
> protection on what it left behind, and any newly added low-trust
> repository could take an orphaned ex-official package over without
> confirmation.

Operators meeting an orphaned package SHOULD audit it: verify the
installed files' hashes against trustworthy out-of-band records, and
consider reinstalling or removing it through a trusted repository.

## 5.37.9 Between repositories

When two configured repositories publish a package of the same name, no
conflict exists at the format level; the consumer resolves which to
install by priority. The same applies to overlapping `provides` or
`replaces` relations: the higher-priority repository's claim wins.

An operator publishing a `provides` that shadows a package of the
official repository SHOULD document it clearly, and a consumer SHOULD
warn when a lower-priority repository's `provides` shadows a
higher-priority package.

Two guards require explicit operator confirmation, and neither may be
satisfied by a general "proceed" affirmation:

- Applying a `replaces` declared by a lower-priority repository against a
  package originally installed from a higher-priority one. A repository
  silently replacing a more-trusted package is a real escalation path,
  and confirmation stops it happening as a side effect of a routine
  upgrade.
- Applying a `conflicts` declared by a lower-priority repository that
  would cause the cascade-removal of a package from a higher-priority
  one. That is a denial-of-availability vector, and confirmation stops a
  low-trust install from silently uninstalling a high-trust package.

A consumer that resolves a conflict by rejecting the plan outright,
rather than by cascading removals, satisfies the second guard vacuously.

## 5.37.10 Compromise response

If a repository's signing key is suspected of compromise, a consumer
SHOULD disable the repository immediately, audit the packages installed
from it for tampering, and, once the operator has published a new
descriptor with the compromised key removed, perform a fresh trust-add
with new anchors.

This version defines no automated revocation mechanism beyond the
`revoked` key status (§5.32). Compromise response is operational.

---

# 5.38 Extension

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> What may be added to these documents without a version bump, what may not, the reserved space, and how deprecation works.

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 `algorithm` fields of the files manifest and the index hash object
  reserve room for a further hash algorithm.
- The `arch` qualifier on a dependency reserves room for explicit
  architecture identifiers, for a multi-architecture system.
- The sibling-artifact paths of §5.36 reserve room for build
  attestations and bills of material.

An implementation of this version MUST reject a value in a reserved
space rather than guess at it.

## 5.38.4 Deprecation

A field this specification requires MUST NOT be removed within a
`schema_version`. When a field becomes unnecessary, a producer continues
emitting it and a future version removes it under a new
`schema_version`.

---

# 5.39 Conformance

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Every requirement collected by role — producer, repository and consumer — and what conformance deliberately does not require.

## 5.39.1 Producer

A conforming producer:

- emits packages satisfying §5.10 through §5.17: the container, every
  determinism rule, the internal layout, the payload path constraints,
  the install destinations, the triplet rule, the entry rules, and the
  symlink rules;
- emits a manifest satisfying §5.18, with names, versions, and
  architectures satisfying §5.3 through §5.8;
- emits a files manifest satisfying §5.25, covering exactly the
  regular-file payload entries, with `size_installed` equal to the sum
  of its sizes;
- declares relationships satisfying §5.21, sorted and unique within each
  field, using the derived-capability names of §5.22 where a capability
  is machine-derived;
- declares claims satisfying §5.23, with every target a payload path of
  its own;
- declares side effects satisfying §5.24, declaring each that its
  payload requires and none that it does not;
- signs packages satisfying §5.28, or emits them unsigned knowing they
  will be accepted only under a permissive policy.

## 5.39.2 Repository

A conforming repository:

- publishes a descriptor satisfying §5.31 with a valid detached
  signature, and serves the public key file of every key the descriptor
  declares, including revoked ones (§5.32);
- publishes an active index satisfying §5.33 and an archive index
  satisfying §5.35, each with a valid detached signature, each derived
  directly from package manifests;
- increases `index_version` strictly on every publication (§5.34);
- retains every version it has ever advertised, and removes a pruned
  package from both its archive index and its storage (§5.35);
- serves everything over HTTPS at the URLs its descriptor declares
  (§5.36).

## 5.39.3 Consumer

A conforming consumer:

- verifies a package by §5.26 in full, including the transaction-wide
  rule and the path-resolution rule, before installing anything;
- enforces the decompression bounds of §5.27 continuously, from the
  index-declared sizes;
- verifies signatures by §5.30, against a trust set scoped to the
  originating repository, honouring key status before any cryptography;
- rejects a package violating any rule of §5.10 through §5.25 — the
  determinism rules and the payload rules included, on the way in, not
  only on the way out;
- enforces the freshness and rollback rules of §5.34 on both indexes,
  and never lowers a recorded floor except by removing the repository;
- establishes and maintains trust by §5.37, including the fingerprint
  comparison, the per-operation warnings, the maximum trusted age, the
  orphan rules, and the two cross-repository guards;
- enforces the security descriptor policy of §5.20;
- invokes side effects by §5.24, once per transaction, by fixed absolute
  path, with a cleared environment, against the root the transaction
  acted on;
- materialises claims by §5.23, and never at a path an installed package
  owns.

## 5.39.4 What conformance does not require

A conforming consumer is not required to resolve dependencies by any
particular algorithm, to store its state in any particular form, to
recover from an interrupted operation by any particular mechanism, or to
offer any particular command surface. Those are its own design, and
§5.1 places them outside this chapter deliberately.

What it *is* required to do is reach the same answer as any other
conforming consumer about whether a given package satisfies a given
dependency (§5.21), and about which of two versions is newer (§5.6).
Those two questions are the ones a producer's declarations depend on,
and they are frozen.

---

# Appendix 5.A Limits and Defaults

_Peios / Advanced Peios / PSPU / Package Format and Repository Protocol_

> Every limit as a minimum conformance figure — package structure, manifest arrays, identity, documents, decompression and repository defaults.

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 closed set in this version — architectures, pre-release ranks, constraint operators, side-effect identifiers and hash algorithms.

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_

> What the Peios kernel is — a Linux kernel with a patch series, the PKM module and a uapi header set compiled into it — and what this manual covers.

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_

> KMES is the sole event emission path in Peios, used by kernel subsystems and userspace alike, and the terms this chapter uses for it.

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_

> What an event is on the wire — the packed header, the stamps the kernel applies, ordering, the msgpack payload and the size limits.

## 2.2.1 Structure

An event is a packed binary header followed immediately by its msgpack
payload, written and delivered as a single contiguous byte sequence
with no padding or alignment gaps anywhere. The header is never
represented as a C struct in the kernel; it is serialised field by
field, in order, directly into the ring buffer. The field-by-field
layout — offsets, sizes, and endianness — is part of the consumer
contract and is defined in the PSPK event stream specification. In
summary: all fields before the event type string sit at fixed offsets,
the event type string begins at offset 77 with its `u16` length at
offset 75, the header is exactly `77 + type_len` bytes, the payload
occupies the bytes from `header_size` to `event_size`, and the next
event begins at offset `event_size` from the start of the current one.
All multi-byte header integers are little-endian; the identity GUIDs
are copied as opaque 16-byte values.

`event_size`, `header_size`, and `type_len` are structural fields
computed by KMES during construction. The emitter supplies only the
event type string and the payload, and KMES copies both verbatim.

## 2.2.2 Intrinsic stamps

KMES populates four intrinsic stamp fields at emission time,
unconditionally; the emitter cannot supply them.

- **`timestamp`** — wall clock time (`CLOCK_REALTIME`, via
  `ktime_get_real_ns()`) at the moment KMES accepts the event, in
  nanoseconds since the Unix epoch.
- **`sequence`** — the emitting CPU's per-boot counter, incremented
  and then read, so the first event on each CPU gets 1.
- **`cpu_id`** — the CPU on which the ring buffer write occurs, which
  identifies the per-CPU buffer holding the event.
- **`origin_class`** — for syscall emission, set unconditionally to 0
  (userspace); the caller cannot influence it. For kernel emission,
  the value the calling subsystem passed, written to the header
  without validation — kernel emitters are trusted to pass an
  assigned value.

The timestamp is captured before the sequence number is assigned, so
two events with the same timestamp on the same CPU are ordered by
sequence.

## 2.2.3 Identity stamps

The three identity GUIDs are captured by calling KACS accessors during
the preemption-disabled ring buffer write phase, after the sequence
number is taken and before the header is built. All three accessors
are safe with preemption disabled — each is a handful of pointer
dereferences and a 16-byte copy, with no allocation and no sleeping.
The stamps therefore reflect the thread's identity at the moment of
the ring buffer write, not at syscall entry.

- `kacs_effective_token_guid()` reads the thread's *subjective*
  credentials (`current_cred()`), so during impersonation — which
  installs the impersonation token via `override_creds()` — it yields
  the impersonation token's GUID. Otherwise it equals the true token
  GUID.
- `kacs_primary_token_guid()` reads the *real* credentials
  (`current_real_cred()`), which impersonation leaves untouched, so it
  always yields the process's primary token GUID.
- `kacs_process_guid()` reads the process GUID KACS assigned when the
  process's security state was created at fork. Threads created with
  `CLONE_THREAD` share the process state, and no exec path writes the
  field, so the GUID is stable for the process's lifetime.

All three accessors return the null GUID when there is no task context
(`!current` or not `in_task()`), and the token accessors also return it
when the token cannot be resolved. A fully null identity triple
therefore indicates emission before KACS initialisation, from a kernel
thread, or from interrupt or softirq context. No event is ever stamped
with the identity of a task that merely happened to be interrupted.

For batch emission, the timestamp and the three identity GUIDs are
captured once, before the per-event loop, and shared by every event in
the batch: the batch executes with preemption disabled on one CPU, so
the emitting thread's identity cannot change mid-batch. Each event
still receives its own sequence number.

## 2.2.4 Ordering

Cross-CPU ordering is by `timestamp`. Events with identical timestamps
from different CPUs were genuinely concurrent and have no defined
relative order. Within one CPU, `sequence` is the reliable ordering
primitive, monotonic even across wall-clock discontinuities; events
with identical timestamps on the same CPU are ordered by sequence.

## 2.2.5 Payload

The payload is a single msgpack value. KMES neither interprets nor
modifies it — buffering and delivery are content-blind — and it
performs no payload validation at all for kernel emitters, which are
trusted callers inside PKM.

Payloads arriving through the syscall interface are validated before
acceptance by an iterative (non-recursive) msgpack walker implemented
in Rust, operating on the kernel's staged copy of the payload:

- The payload is exactly one well-formed top-level msgpack value.
  Trailing bytes after it are rejected, as is the never-used `0xc1`
  type byte. A zero-length payload is rejected — an empty byte
  sequence is not a msgpack value — so syscall events always carry a
  payload. (Kernel emitters can emit header-only events with
  `event_size == header_size`.)
- Nesting depth is bounded by the configured `MaxNestingDepth`
  (§2.6). Depth counts from 1 at the top-level value; each child of an
  array or map sits one deeper than its container. A non-empty
  container at the maximum depth is invalid, because its children
  would exceed the limit; an empty array or map at the maximum depth
  is valid, consuming no depth. Map keys and values each occupy a
  child slot, so a map contributes twice its entry count of children.
- The walker's own stack is 256 frames, matching the upper bound of
  the `MaxNestingDepth` range; a configured depth outside 1–256 causes
  every payload to be rejected rather than any to be waved through.
- Length prefixes inside msgpack are big-endian, per the MessagePack
  specification — the one big-endian ingredient in an otherwise
  little-endian event.

A rejected payload fails the syscall with `EINVAL` and nothing is
written to the ring buffer.

The event type string is validated as UTF-8 on the syscall path by the
same staged-copy pass; kernel emitters' type strings are trusted and
copied as given. Types are compared by consumers as raw bytes — no
case folding or normalisation is applied anywhere.

## 2.2.6 Size limits

Three structural bounds apply to every event, and one configurable
policy bound applies to syscall emitters only:

- The event type length fits the header's `u16` `type_len` field and
  is nonzero. Syscall emitters cannot express an overlong type — the
  ABI length field is already `u16` — but the kernel emission API
  takes a `size_t` and enforces the bound itself.
- The total event size (header + payload) fits a `u32`, checked with
  overflow-safe arithmetic on the declared lengths.
- The total event size does not exceed 50% of the per-CPU ring buffer
  capacity — a fixed ratio, not configurable, protecting a CPU's
  event history from a single giant event. An event of exactly half
  the capacity is accepted. Capacity is always a power of two, so the
  halving is exact.
- Syscall events are additionally bounded by the registry-configurable
  `MaxEventSize` (§2.6), rejected with `ENOSPC` when exceeded. Kernel
  emitters are exempt from this policy limit.

---

# 2.3 Emission API

_Peios / Advanced Peios / PKM / KMES_

> The in-kernel emission interface — single and batch emission, the structural checks that drop an event, and what happens when a ring is full.

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:

1. One wall clock timestamp is captured; every event in the batch
   shares it.
2. The three identity GUIDs are captured once and shared.
3. Each event, in order: structural checks, sequence assignment,
   header build, ring write. The batch structural check is slightly
   stronger than the single-emission one — it also rejects a null
   type pointer, a null payload pointer with a nonzero length, and a
   header size beyond `u32`. A failing event is dropped exactly as in
   single emission (sequence consumed, gap visible, counter
   incremented) and the batch continues with the next event — kernel
   emitters are trusted, and an individual structural failure
   indicates a kernel bug rather than hostile input. This differs
   deliberately from the syscall batch, which stops at the first
   failure so the untrusted caller learns which entry was bad.
4. After the loop, provided at least one event was actually written,
   the new tail position and then the new write position are published
   with release stores — one publication for the whole batch — and
   the consumer wake flag is checked once, incrementing the futex
   counter if a consumer is asleep. A batch in which every event
   failed publishes nothing and performs no wake check.
5. Preemption 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_

> The three KMES syscalls — emit, batch emit and attach — with their privilege gate, rate limiting, validation and preemption behaviour.

KMES exposes three syscalls in the PKM syscall range (1090–1099):
`kmes_emit` (1090) emits a single event from userspace, `kmes_attach`
(1091) attaches the caller as a consumer of one per-CPU ring buffer,
and `kmes_emit_batch` (1092) emits multiple events in one operation.
All three follow the standard Linux convention — they return −1 and
set errno on failure — and their numbers, entry struct layout, error
tables, and privilege masks are collected in §2.A.

Before KMES initialisation completes, all three syscalls fail with
`ENOMEM`. Before KACS initialisation, the emit syscalls fail closed
with `EPERM`, since privilege checks cannot be performed.

## 2.4.1 kmes_emit

Emits one event. The origin class is set to 0 (userspace)
unconditionally — the caller cannot choose it — and the event is
written to the ring buffer of the CPU the calling thread is executing
on at write time.

### 2.4.1.1 Privilege gate

The caller's effective token has to hold SeAuditPrivilege, enabled;
otherwise the syscall fails with `EPERM`. A successful gate records
SeAuditPrivilege as used on the token, as a KACS standalone privilege
gate; a failed gate records nothing. If recording the used state
itself fails, the syscall also fails with `EPERM`.

### 2.4.1.2 Rate limiting

Callers without enabled SeTcbPrivilege are rate limited per process by
a token bucket: the refill rate and the burst capacity both equal the
configured `MaxEmitRatePerProcess` (§2.6). The bucket is allocated
together with the process's KACS security state at fork, initialised
to full capacity, and freed when the process's security state is
released at exit. Refill is computed against the monotonic clock, so
wall-clock jumps do not affect it. When `MaxEmitRatePerProcess`
changes at runtime, the new rate and capacity take effect immediately
— rates are read live on every operation — and each bucket's current
token count is clamped down to the new capacity.

A token is reserved up front and refunded if the syscall subsequently
fails, so validation failures cost nothing; the refund is clamped to
capacity, which means a refund landing just after a rate decrease can
forfeit the token. An empty bucket fails the reserve with `EAGAIN`,
consuming nothing. Callers holding enabled SeTcbPrivilege bypass the
bucket entirely, and the exemption records SeTcbPrivilege as used.

Rate state is per-process rather than per-SID: per-SID limiting would
penalise unrelated services sharing a SID (LocalService, for
instance). A process that forks to reset its limit is bounded by
`RLIMIT_NPROC`.

### 2.4.1.3 Validation

Validation runs in order and stops at the first failure; the errno
reflects the first failing check.

1. The privilege gate and rate reservation, above.
2. `event_type_len` is nonzero — `EINVAL` otherwise.
3. The declared total event size (`77 + type_len + payload_len`) is
   computed from the length fields alone, without dereferencing either
   userspace pointer, with overflow-checked arithmetic — overflow is
   `EINVAL`.
4. The declared size is within `MaxEventSize` — `ENOSPC` otherwise.
5. The declared size is within 50% of the ring capacity — `ENOSPC`
   otherwise. At this stage the check runs against the first live
   ring's capacity; it is repeated against the actual target CPU's
   ring inside the write phase, so a capacity swap racing the syscall
   can surface `ENOSPC` after all other validation has passed.
6. The event type and payload are copied into a kernel staging buffer
   (`EFAULT` if a pointer is inaccessible, `ENOMEM` if allocation
   fails). Everything after this point — validation and the ring
   write — operates on the kernel copy, closing the TOCTOU window in
   which userspace could rewrite the payload after validation.
7. The event type is validated as UTF-8 — `EINVAL` otherwise.
8. The payload is validated as msgpack within `MaxNestingDepth`
   (§2.2) — `EINVAL` otherwise.

### 2.4.1.4 Preemption

Validation runs with preemption enabled — the userspace copies can
fault, and msgpack validation of a large payload takes microseconds.
Preemption is disabled only around the ring buffer write: determining
the CPU, stamping, writing, publishing `write_pos`, and checking
`need_wake`. The `cpu_id` and identity GUIDs therefore reflect the
thread's state at write time, not at syscall entry. On success the
syscall returns 0 and the event is immediately visible to consumers.

## 2.4.2 kmes_emit_batch

Emits up to 256 events in one call, sharing the privilege check, the
timestamp, the identity capture, and the single `write_pos`
publication across the batch. The 256-entry cap bounds the
preemption-disabled write window to roughly 50–100 microseconds for
typical event sizes.

The caller passes an array of 32-byte entry descriptors (layout in
§2.A; the descriptor padding bytes are documented as
reserved-must-be-zero in the ABI header but are not validated), a
`count`, and an `emitted_out` pointer.

Processing order:

1. The SeAuditPrivilege gate, as for `kmes_emit`.
2. `count` is within 1–256 — `EINVAL` otherwise.
3. `count` tokens are reserved from the rate bucket in one critical
   section, so concurrent threads cannot both pass the check —
   `EAGAIN` if unavailable, and nothing is emitted. SeTcbPrivilege
   exempts as before.
4. Zero is stored to `*emitted_out` before any per-entry work —
   `EFAULT` if unwritable, with nothing emitted.
5. The descriptor array is copied from userspace (`EFAULT`/`ENOMEM`).
6. Each entry, in order, goes through the same staging pipeline as
   `kmes_emit` — declared-size arithmetic, `MaxEventSize`, 50%
   capacity, userspace copy, UTF-8, msgpack. Staging stops at the
   first failing entry.
7. The validated prefix is emitted in one preemption-disabled write
   phase: one timestamp, one identity capture, a sequence number per
   event, origin class 0 throughout, and a single deferred
   publication that makes the whole prefix visible atomically.
8. Unused tokens (`count` minus events emitted) are refunded — only
   events actually emitted are charged.

On full success the syscall returns 0 and writes `count` to
`*emitted_out`. If entry N fails, entries 0 through N−1 are emitted,
N is written to `*emitted_out`, and the syscall returns −1 with the
errno of the failing entry. Failed entries never consume sequence
numbers, so batch validation failures leave no consumer-visible gap.
The final `emitted_out` store is a second write to userspace; if it
faults, the syscall reports `EFAULT` even though the prefix was
already emitted.

Every staged entry is held in kernel memory simultaneously until the
write phase completes, so a batch's transient allocation is bounded by
`count × MaxEventSize` — up to 1 GB at the maximum settings — rather
than by a single event.

## 2.4.3 kmes_attach

Attaches the caller as a consumer of one per-CPU ring buffer,
returning a file descriptor. The consumer contract built on this fd —
the mapped region layout, the drain and notification protocols, and
the re-attach protocol across buffer swaps — is specified in the PSPK
event stream chapter; the TRM side of the mechanics is §2.5.

The caller's effective token has to hold SeSecurityPrivilege, enabled
— `EPERM` otherwise — and a successful gate records SeSecurityPrivilege
as used. `cpu_id` is a logical CPU index using the same numbering as
the ring metadata and event headers.

The slot array is allocated at KMES initialisation and sized by
`nr_cpu_ids`, then filled by walking `for_each_possible_cpu`. Those two
quantities are not the same thing: the array size bounds a valid
`cpu_id`, while the ring count is however many of those slots got a
ring. They agree only when the possible-CPU mask is dense. An index at
or beyond the array size fails with `EINVAL`; so does an index inside
it whose slot holds no live ring.

A consumer learns the array size by calling `kmes_attach` with
`cpu_id` set to `KMES_ATTACH_QUERY_SLOTS` (`0xFFFFFFFF`). The call
takes the same privilege gate, writes the slot count through
`capacity`, returns 0, and opens no descriptor. Enumeration then walks
0 to slots-1 and skips the indexes that answer `EINVAL`.

Counting up until the first `EINVAL` — which is what this interface
used to ask for — is wrong on a sparse mask: it stops at the first
hole, and every ring above it becomes permanently unreachable, filling
and overwriting with no consumer able to attach.

CPUs that were possible but offline at initialisation have rings and
are attachable; hotplug beyond the initial set is not handled (§2.7).

On success the current ring capacity is written to `*capacity` — the
consumer computes its mmap size as `8192 + 2 × capacity` — and the fd
is returned. The fd is opened `O_RDWR | O_CLOEXEC` and supports
exactly two operations: `mmap()` and `close()`. The fd is installed
before the capacity write-back; if that write faults, the fd is closed
again and the syscall returns `EFAULT`, but another thread of the
process can have observed the fd in the interim.

Repeated attaches to the same CPU are permitted and return a new fd
each time; all fds for one CPU share the same ring — producer
metadata, consumer metadata, and data region — so multiple direct
consumers can drain one buffer concurrently, each keeping its own
read position in its own memory. KMES stores no per-consumer state:
the fd's private data is a reference to the ring, nothing more. When
events arrive and `need_wake` is set, KMES increments that buffer's
futex counter and wakes all waiting threads.

---

# 2.5 Ring Buffers

_Peios / Advanced Peios / PKM / KMES_

> One ring per logical CPU — how they are organised, the two producer metadata pages, wrap handling, the write protocol and notification.

## 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_

> The four parameters KMES reads from the registry, how it bootstraps before LCS exists, and the watch that keeps them current.

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

1. PKM loads. KMES initialises with compiled-in defaults and creates
   per-CPU rings at the default capacity. They are live immediately.
2. The first Machine-hive source registers, making LCS usable. KMES
   enumerates every value under `Machine\System\KMES\`.
3. Valid values are applied. A `BufferCapacity` differing from the
   current one drives a swap; a matching or absent one changes
   nothing.
4. KMES arms a persistent watch on the key through LCS's internal
   watch mechanism — a kernel-internal registration, not a
   userspace fd-based watch. Delivery is filtered to value-set and
   value-deleted notifications on the key itself, so changes in keys
   below `Machine\System\KMES` do not trigger a re-read.
5. If the key does not exist yet, the fallback watch is armed on the
   Machine hive root and fires on subkey creation at any depth. When
   it fires, KMES re-runs the whole bootstrap: discover the key, read
   it, and re-arm the targeted watch. Deleting the key afterwards
   does not re-arm the fallback.
6. On subsequent changes — administrator edit, or a Group Policy push
   at a higher-precedence layer — the watch fires and KMES re-reads,
   validates, and applies or rejects.

## 2.6.4 Access to the configuration

The configuration keys inherit the Machine hive root security
descriptor, which grants `KEY_ALL_ACCESS` to SYSTEM and
Administrators and `KEY_READ` to Authenticated Users, so unprivileged processes cannot
change KMES's operational parameters. Enforcement is LCS's, not
KMES's — KMES reads values that LCS has already decided the caller
was entitled to write. Domain policy at a higher-precedence layer
provides defence against a compromised local administrator, since
creating a layer above precedence 0 requires SeTcbPrivilege.

The boot-time capacity is the compiled-in default and is not
separately configurable: making it so would need a channel to deliver
a value to the kernel before the registry exists. Once LCS is
available, capacity changes go through the ordinary swap.

---

# 2.7 Failure Modes

_Peios / Advanced Peios / PKM / KMES_

> Ring overrun, event drops, consumer crashes, buffer swap failure, LCS unavailability and clock discontinuity — what each does.

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 KMES syscall number, structure layout, ring-buffer offset and constant, generated from the uapi headers and measured by compilation.

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 &lt;pkm/kmes.h&gt; 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:

```text
__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:

```text
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 &lt; 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_

> What the KMES ABI tables cannot say for themselves — what each syscall parameter means, the privileges each requires, the error vocabulary, the implementation bounds outside the header, and the build configuration.

§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_

> KACS is the security core of Peios — an LSM inside PKM providing identity-based access control — and how it relates to MS-DTYP.

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 thread's identity and security policy — how it relates to Linux credentials, primary against effective, and the evaluation context.

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_

> Every token field by mutability class — fixed identity, adjustable privileges, one-way elevation — and what each carries.

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_

> How tokens follow a process through fork, clone and exec — NEW_PROCESS_MIN, self-installation, bootstrap tokens and external 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.

1. 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.
2. If the file's level is lower than the token's, a new token is
   created following DuplicateToken semantics — new `token_id`, new
   `token_guid`, `modified_id` initialised to the new `token_id`,
   `elevation_type` reset to Default — with `integrity_level` set to
   the file's label. Every other field is copied from the source, and
   the original token is dropped.
3. If the file's level is greater than or equal to the token's,
   nothing happens and the token survives exec unchanged.

The mechanism only ever lowers integrity, so a child's level is always
at most its parent's. 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_

> The three operations that produce a token — CreateToken from nothing, DuplicateToken from another, FilterToken for a restricted one.

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_

> What can be changed on a live token — privileges, groups, interactivity scope and object-creation defaults — and what cannot.

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_

> Two linked tokens for one principal — what the kernel does with the link, what it deliberately leaves to userspace, and the pair's lifecycle.

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_

> The LUID-identified object every token references — how sessions are created, how they expire, and how revocation propagates.

## 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:

1. authd decides a session has to end — an admin request, a security
   incident, an account deletion, or a user logging off.
2. authd enumerates processes whose tokens carry the target `auth_id`
   or `interactivity_scope` by walking `/proc/*/token`, opening each
   node's query-only inspection handle, and reading `TokenStatistics`,
   which includes `auth_id`. No dedicated enumeration syscall exists
   or is needed.
3. authd requests termination — through peinit for supervised
   services, through signals for user processes.
4. The processes terminate, dropping their token references.
5. 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 — obtaining a token file descriptor, the token-specific rights, the generic mapping and the default descriptor.

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 per-process structure describing what a process is, independent of the principal running it.

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_

> Every PSB field — the process GUID fixed at fork, the protection fields set at exec, the one-way mitigations and the UI access flag.

## 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_

> The descriptor controlling who may operate on a process — the access rights, signal classification, what bypasses the check, and the default.

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_

> What the PSB does across fork, exec and CLONE_THREAD, and how it feeds AccessCheck.

## 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_

> Rights that do not fit the subject-object model — the privilege lifecycle, the two enforcement categories, intent gating, assignment and auditing.

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_

> Every Peios privilege with the bit it occupies in the token's four privilege words, grouped by what it governs.

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_

> The four levels at which a server thread may assume a client's identity, and what each permits.

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_

> The two independent checks that decide whether an impersonation proceeds at the level asked for — the identity gate and the integrity ceiling.

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_

> Assuming and reverting an identity — the sequence, Anonymous, double impersonation, and how MIC and PIP interact with it.

## 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_

> How the kernel verifies signatures on executables and libraries — the key table, finding a signature, hashing, and PIP determination at exec.

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_sig`
  returns `ENOENT` on every boot. A probe there would fire always.
- A non-zero return from an LSM's init function is only `WARN`'d
  (`security/lsm_init.c`, `lsm_init_single`). The hooks are never added,
  so "refuse to initialise" means running with no KACS at all — worse
  than the failure it would be preventing.

Enforcement therefore lives at exec, where refusing one exec is
recoverable in a way losing the integrity boundary is not.

The probe is not `IS_ENABLED(CONFIG_CRYPTO_MLDSA)`. That option is an
unconditional `select` under `SECURITY_PKM`, which is a `bool`, so a
config test would always pass and catch nothing. Only calling the
allocator sees the ordering failure.

## 3.6.4 PIP determination at exec

At `execve()` the kernel looks up and verifies the signature as above.
A verified binary takes `pip_type` and `pip_trust` from the matched
key; no signature, an invalid or unstable one, a bad signature, or no
matching key all yield None/0.

**Exec proceeds in every case where the question could be answered.**
PIP is additive protection, not an execution gate: it determines trust
level, not permission to run. The one exception is a signature that
could not be verified at all, described above, which refuses the exec —
because there the question was not answered, so there is no basis on
which to assign a trust level. An
attacker who replaces a signed binary's signature with garbage costs
it PIP protection but can still execute it, subject to FACS. The
asymmetry with LSV — which *does* block unsigned libraries — is
intentional. Exec is permissive; `mmap(PROT_EXEC)` is restrictive.

Determination is transactional with exec success, in three phases. The
pending value is cleared at the top of every `bprm_creds_from_file`
invocation, staged into the task's security blob, and committed to the
process state only from `bprm_committed_creds`. An exec that fails
between staging and commit leaves the process state untouched, and the
stale pending value is cleared by the next exec or at task teardown.

Because the hook fires once per binfmt iteration and each iteration
re-stages, a `#!` script's PIP comes from the **interpreter** — the
last file processed — not from the script. A TCB-signed interpreter
runs at TCB level whatever script it executes. Symlinks need no
special handling either: the LSM hooks receive the already-resolved
target file, so a symlink inherits its target's level by construction.

Once committed, the values are fixed for the lifetime of the process
image, inherited at fork, and re-derived at the child's exec.

## 3.6.5 Content pinning

A binary that verifies to a nonzero tier has its backing inode pinned
as KACS-verified executable content before the exec result is
committed, and LSV pins on success too, before allowing the mapping.
Pinning closes the gap between verifying one byte image and modifying
the same inode afterwards.

If pinning fails at exec, the PIP result is **downgraded to None/0**
rather than the exec being failed or the tier being kept — the process
runs unprotected. Under LSV a pin failure denies the mapping instead.

A pinned inode rejects in-place content mutation even when the size
would be preserved: ordinary, positioned and append writes;
`ftruncate()` and pathname `truncate()`; and every `fallocate` mode,
including allocation-only ones, because the pin check runs before and
independently of the mode-support test. File ioctls that mutate
content, ranges, or allocation are rejected, and so are ioctls the
kernel cannot classify — unknown ioctls fail closed on a pinned inode.
Mutation or removal of the `security.peios.sig` xattr is rejected as
well.

The pin is conservative and one-way. It is set once and cleared only
when the inode is allocated or freed, never while the inode is live.
Updating verified executable content therefore means replacing the
inode — write a new file and `rename()` over it — rather than
modifying it in place.

Unsigned, invalid, unstable, bad-signature and no-match attempts never
pin.

## 3.6.6 Library Signature Verification

With the `lsv` mitigation enabled, `mmap()` with `PROT_EXEC` on a
file-backed mapping verifies the backing file. An unsigned file, an
invalid or unstable one, a bad signature, or no matching key all deny
the mapping with `EACCES`. On success the library's tier is compared
against the loading process's: the image has to dominate the process,
so a Protected/PeiosTcb process can load only PeiosTcb-or-above
libraries. With one key in the table this reduces to "is it signed
with the TCB key?"

The hash covers the **entire file**, not the mapped region — the whole
file is read and hashed even when only part of it is being mapped, so
the signature covers code in sections this particular mapping does not
touch.

`mprotect()` adding `PROT_EXEC` to a mapping that was not already
executable runs the same checks in a fixed order: WXP, then TLP, then
LSV. Anonymous mappings have neither a path nor a signature, so TLP
and LSV skip them and only WXP applies.

Enabling `lsv` on a running process also re-validates its existing
executable mappings before the bit is committed (§3.3.2), so the
mitigation cannot be turned on over already-mapped unsigned code.

## 3.6.7 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 through trust label ACEs and processes through a dominance test — where it is enforced, and the limits of what it can do.

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_

> The function that connects tokens to descriptors — its two API variants, and the three state values a right can hold during evaluation.

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_

> Walking the ACEs in order under first-writer-wins — SID matching, absent and empty DACLs, owner implicit rights and MAXIMUM_ALLOWED.

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 restricts what the DACL is allowed to grant along a vertical trust hierarchy — the labels, the policy bits, and the algorithm.

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_

> The second pass a restricted token forces — when it runs, how privileges bypass it, and how owner rights and virtual groups behave in it.

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_

> Per-property access control through GUID-scoped ACEs — object type lists, propagation, PRINCIPAL_SELF, and per-node results.

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_

> An absolute boundary a confined token cannot cross whatever the DACL says — normal and strict confinement, and where it sits in the order.

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_

> How a trust label ACE is evaluated — the label SID, the privileges it revokes, the algorithm, and where the compared values come from.

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_

> Policy defined once and referenced by objects — the policy structure, access and audit evaluation, the cache, and the recovery policy.

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 and never changes a decision — access, continuous and privilege-use auditing, and per-token policy.

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_

> How every layer composes — the definitive statement of the evaluation order, from EvaluateSecurityDescriptor down through AccessCheckCore.

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:

0. **Impersonation level gate.** An impersonation token at
   Identification level is denied immediately. Anonymous tokens
   proceed through the full pipeline.
1. **Input validation.** Reject a descriptor with no owner. A null
   group SID is valid and has no direct effect on the decision.
2. **Generic mapping.** Map generic bits in the desired mask to
   object-specific bits; strip `MAXIMUM_ALLOWED`.
3. **Effective privileges.** Clear the backup and restore bits when
   the corresponding intent flag is absent.
4. **Privilege grants.** Resolve `ACCESS_SYSTEM_SECURITY`, backup and
   restore. Seed `decided`, `granted` and `privilege_granted`.
5. **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.
6. **Virtual group resolution.** `S-1-3-4` and `S-1-5-10` become
   matchable where the caller is the owner or the object's principal.
7. **Tree initialisation.** Seed each node from the scalar state.
8. **Normal DACL evaluation.** Owner implicit rights, then the walk.
9. **Post-DACL WRITE_OWNER override.** `SeTakeOwnershipPrivilege`
   grants `WRITE_OWNER` if the DACL did not and no mandatory
   mechanism blocked it.
10. **Restricted token pass**, with intersection and privilege
    restoration.
11. **Confinement pass**, with absolute intersection.
12. **CAAP.** Evaluate each applicable rule's DACL through the full
    per-descriptor pipeline and intersect; collect SACLs.
13. **Privilege-use auditing.**
14. **Audit emission**, over the object's SACL and any CAAP SACLs.
15. **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 is the file-specific enforcement surface of KACS — mount policy, scope, how a handle is acquired, and stacked backing files.

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_

> Opening a file by naming every right up front — the required data right, directories, special nodes, create dispositions and the DELETE fallback.

`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 open flags cannot express WRITE_DAC or READ_CONTROL — how FACS maps them to core and compat rights, and what O_PATH does.

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 — data, metadata, traversal, append-only, fcntl and ioctl.

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_

> Where a file's descriptor lives and why raw xattr access is denied in all three directions — plus caching, mount policy classes and boot artifacts.

## 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_

> The single syscall all descriptor modification flows through — ownership, integrity labels, the SeRestorePrivilege bypass and mandatory attributes.

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_

> Copy-up is the internal realisation of an already-authorized operation — its admission and lifetime, object and phase binding, and exempt operations.

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_

> Linux applications call getuid() and know nothing of tokens — how KACS projects a token onto Linux credentials, one way only.

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 before any LSM hook fires, so KACS has to neutralise it — the capability switchboard, the compatibility state and the LSM stack.

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 — what the setuid syscalls do, what the setuid bit does at exec, and the compatibility gaps that remain.

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 KACS syscall number, structure layout, constant and enumeration, generated from the uapi headers and measured by compilation.

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_

> Where KACS departs from MS-DTYP despite using its binary formats, and which features are handled elsewhere rather than here.

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_

> The audit records KACS emits through KMES — the event families, their shared payload records, and how they are delivered.

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](/peios/using-peios/events/kernel-access-events/access-audit.md), 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_

> What the KACS ABI tables cannot say for themselves — token query payload shapes, the specification names that differ from the headers, what is documented elsewhere, and the kernel configuration.

§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 as one tree — where it sits, the model it follows, and what it delegates.

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 its stratum stack — an ordered, non-empty list fixed for the mount's life — plus the resolution context and the create stratum.

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_

> The single filesystem-specific mount parameter stratafs registers, its escaping and parse failures, generic flags, and remount.

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_

> What a caller must be entitled to and what a configuration must satisfy — evaluation order, loop detection, and mount immutability.

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 — what happens while it is absent, and what appearing, disappearing and reappearing do.

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_

> Resolving one name in one directory — the provider, ancestors, independence from the caller, and staging entries.

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 `ioctl`s 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_

> When lower strata hold the same name as a directory the result is a merged directory — participation, its create stratum, and symlinks.

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 holding one name with different types — the provider's type wins, masking is total, and the result is stable.

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_

> The union of names across participating strata, each appearing once — captured at open, with consistency and position rules.

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_

> Strata change underneath stratafs without telling it — what can change a resolution, and the guarantee that survives it.

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 caching the specification permits, the version tuple and identity comparison it rests on, and what always invalidating costs.

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 allocates its own inodes standing for provider objects — the identity it reports, the mount root, and what a provider change does.

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_

> Which single stratum a modification is performed against, when that decision is made, and what shared writable mappings do to it.

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.

1. If the **mount** is read-only, the route is read-only. This term
   short-circuits everything else.
2. If the provider accepts modification, the operation is performed
   against the provider's object.
3. Otherwise, if the object is copyable, a create stratum exists, is
   present, and has **strictly higher precedence than the provider**,
   the object is copied up and the operation performed against the
   copy.
4. Otherwise the route is read-only and the operation fails with
   `EROFS`.

The strict `create_index < provider` comparison is what stops a
modification being placed where something already present would shadow
it. Where a high-precedence stratum provides a name it will not accept
a write for, there is no lower stratum that can take the write without
the result vanishing behind the provider, so `EROFS` is the honest
answer — reported at the moment of the write rather than discovered
later.

Where the name is held by no stratum, the operation is a creation and
§4.5.3 applies instead.

## 4.5.1.3 When the decision is made

Routing happens when a modifying operation is performed, not when a
descriptor is opened. Every mutating entry point calls
`route_existing` afresh against the provider it currently holds;
nothing caches a route decision, and a descriptor opened before the
predicate changed is not revisited.

| 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 `ioctl`s 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_

> Replicating an object from its provider into the create stratum — parents, what is replicated, staging and atomicity.

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 no stratum holds — where it lands, its security descriptor, deleting dispositions, and exclusive against non-exclusive creation.

Creating a name held by no participating stratum places it in the
create stratum.

1. If there is no create stratum, or it is absent, the operation fails
   with `EROFS`.
2. Any directories of the create stratum containing the name's path
   that do not exist are materialised as §4.5.2 requires for copy-up
   parents. Where the create stratum holds one of those components as
   something other than a directory, the operation fails with
   `ENOTDIR`; the blocking entry is not removed or replaced.
3. The name is created there.

All six kinds of creation — regular file, directory, symbolic link,
device node, FIFO, socket — go through one helper and one path.

The `ENOTDIR` case arises because creation routes positionally, into
the create stratum's subdirectory at the same path whether or not it
exists (§4.3.2). Where the create stratum holds that path as a file,
and a higher-precedence stratum provides it as a directory, the merged
directory exists and is reachable while its create-stratum counterpart
is blocked.

## 4.5.3.1 The security descriptor

A created object has no provider to inherit from, so its descriptor is
established by the ordinary creation semantics of the create stratum's
filesystem — by inheritance from the directory it is created in,
exactly as if it had been created there directly. The create is an
ordinary `vfs_create`, `vfs_mkdir`, `vfs_symlink` or `vfs_mknod`
against the real create-stratum parent, with the KACS creation decision
bound to that same parent.

This is cleanly separated from copy-up inside KACS: the copy-up branch
of the inode security initialisation is taken first and short-circuits
the inheritance builder entirely. Ordinary creation in a create stratum
inherits; copy-up preserves.

Where the creating interface lets the caller supply a descriptor — the
native open path does — it is honoured rather than replaced by an
inherited one. That is entirely KACS's doing; stratafs has no
descriptor parameter and cannot express it. All stratafs contributes is
re-anchoring the pending native create request onto the create-stratum
parent.

## 4.5.3.2 Dispositions that delete

A creating interface may offer a disposition that replaces an existing
object rather than opening it. In Peios that is the supersede
disposition of the native open path: KACS creates a temporary file
through the mount, opens it, and renames it onto the target, at which
point stratafs diverts the rename into a dedicated supersede path.

Such a disposition 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, so removal can only remove an entry that is really there — and what removal therefore never does.

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 and creates, so the source is bound by the removal constraint — atomic replacement, flags and RENAME_NOREPLACE ordering.

Rename both removes a name and creates one. Because stratafs cannot
make a name disappear (§4.5.4), the constraint on the source is the
same as for removal, and the constraint on the destination follows from
the read-after-write direction of §4.5.1.

For a rename of a source to a destination within one mount, letting `P`
be the source's provider:

1. `P` must accept modification. Otherwise `EROFS` — the source cannot
   be removed, so the rename would leave it still visible and amount to
   a copy.
2. The destination must not be provided by a stratum of **higher**
   precedence than `P`. Otherwise `EROFS` — the renamed object would be
   shadowed at its destination and unreadable through the path it was
   renamed to.
3. `P` must hold the directory containing the destination. Otherwise
   `EXDEV`. That directory is not created to satisfy the condition,
   whether or not `P` is the create stratum: parent materialisation
   exists to receive a copy-up, and a rename is not one.
4. Where the source is a directory, the merged directory must contain
   no entries provided by a stratum other than `P`. Otherwise `EXDEV`,
   since those entries cannot move with it. Determining this reads
   every participating stratum, so traverse and list rights are
   required on each, checked before any enumeration begins; where that
   is refused the rename fails with `EACCES` without disclosing whether
   other strata contributed.
5. Where the destination is provided, its type must match the source's.
   A non-directory onto a directory provider fails with `EISDIR`; a
   directory onto anything else fails with `ENOTDIR`. Only the
   provider's type is compared, since a lower stratum's entry of a
   different type is masked and contributes no inode.
6. Where the destination's provider is a directory, that merged
   directory must contain no entries at all — the same merged-emptiness
   test `rmdir` uses, with the same access requirement. Otherwise
   `ENOTEMPTY`.
7. The rename is performed within `P`: both parents are resolved at the
   provider's index, and a single `vfs_rename` is issued there.

Where the destination is provided by a stratum of lower precedence than
`P` and both are non-directories, the rename succeeds and the renamed
object shadows it. Where the destination is held by `P` itself, it is
replaced, as on any filesystem.

Where the renamed object and the destination are both directories, the
result is not a shadowing: by §4.3.2 the renamed directory merges with
the lower strata's directories of that name. Condition 6 is what keeps
that tolerable — those directories are empty, so the merged result is
the renamed directory's own contents.

`P` need not be the create stratum. A rename is performed in whichever
stratum provides the source, provided that stratum accepts
modification. A rename whose source and destination are in different
mounts fails with `EXDEV`, refused by the VFS before stratafs is
consulted, and stratafs additionally refuses one spanning two mounts
inside the provider stratum.

The immutable-provider caveat of §4.5.4 applies to condition 1 as well:
an immutable source yields `EPERM` from the VFS rather than `EROFS`.

## 4.5.5.1 Replacing atomically

A rename onto an existing destination is the operation by which most
software replaces a file safely, and it works through a stratafs mount
for a destination provided by a lower stratum. That case is permitted
by condition 2: the destination is shadowed by the renamed object
rather than removed, so no whiteout is required, and the replacement is
a single `vfs_rename` within one directory pair of one stratum — atomic
on the filesystem holding `P`.

Where the source of such a rename is a temporary file the caller
created in the same directory, §4.5.3 placed it in the create stratum,
which never carries `ro`. Conditions 1 and 3 are then satisfied.

The contrast with the supersede disposition (§4.5.3) is worth noticing:
that spans two strata and is not atomic.

## 4.5.5.2 Flags

Conditions 1 and 4 concern whether the source can be moved at all, and
apply under every flag. Conditions 2, 3, 5 and 6 concern the
destination.

| 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_

> Hard links within one mount, linking an unnamed file, and how symbolic links are created.

## 4.5.6.1 Hard links

To create a hard link from an existing name to a new name within one
mount, letting `P` be the source's provider:

1. `P` must accept modification. Otherwise `EXDEV`.
2. The destination must not be held by any participating stratum.
   Otherwise `EEXIST`.
3. `P` must hold the directory containing the destination. Otherwise
   `EXDEV`. That directory is not created to satisfy the condition,
   whether or not `P` is the create stratum.
4. The link is created in `P`.

`P` need not be the create stratum: a link is made in whichever stratum
provides the source, since that is the only stratum in which the two
names can share an object. Condition 2 ensures the new link is not
shadowed at its destination, since no stratum — above `P` or below it —
holds that name.

Condition 2 is enforced by the VFS rather than by stratafs for a named
source: the link path looks the destination up with `LOOKUP_EXCL`, and
stratafs's merged lookup makes the dentry positive whenever any stratum
holds the name. stratafs's own test covers only the provider stratum
and is a race backstop. For an unnamed source it does check every
stratum explicitly.

A link request is never satisfied by copying the source up. The result
would be a link to the copy rather than to the object named by the
source, so the two names would not share an object — which is the whole
of what a hard link is for. Refusing is the only correct answer.

`EXDEV` is used rather than `EROFS` because it is the error callers
already handle when a link cannot be made between two locations, and
because it is accurate: the link would have to span two strata, which
through this mount are two filesystems.

Where installing the outer inode fails after the lower link succeeded,
the link is rolled back; a failed rollback is audited.

## 4.5.6.2 Linking an unnamed file

An unnamed file created under §4.5.3 is linked into the mount by the
rules for creation, not by those above: it has no provider, so
conditions 1 and 3 have no subject.

The link is created in the create stratum — a source recorded with any
other index is refused with `EXDEV` — and the operation follows §4.5.3:
the name must not be held by any participating stratum, parent
directories are materialised in the create stratum, and a create
stratum that is absent or does not exist fails with `EROFS`.

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 locks across a stack of independently writable directories — the rule, a changing provider, the retired provider, and leases.

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 `flock` **unlock** is applied to both the retired file and
  the copy; a non-unlock request goes only to the copy.
- A lease release is applied to both. A lease *acquisition* is
  redirected to the retired file when that file already holds a lease
  of the same flavour.
- Querying a lease returns the stronger of the two files' lease types,
  ordering write above read above none.
- Closing the descriptor runs the underlying flush and removes POSIX
  locks on both files, and releasing it breaks leases on both, using
  the outer file as the owner identity.

All of it is serialised by the descriptor's mutation lock.

## 4.5.7.4 Leases and mandatory locking

Leases are established on the provider's object, in that object's own
lock space, and every result is the provider's own, returned unchanged.
stratafs neither adds to nor removes from whatever semantics the
provider's filesystem gives them, and never reports a lock as
established where the provider's filesystem refused it.

Mandatory locking has no subject here: the platform does not offer it,
and stratafs contains nothing that would obstruct it. The outer inode
copies the provider's inode flags wholesale.

## 4.5.7.5 Internal locks

The specification names no internal lock and fixes no acquisition
order. The implementation's hierarchy, outermost first:

1. The per-open-file **mutation lock**, taken by every operation that
   may copy up.
2. The per-outer-inode **rebind lock**, taken inside it whenever an
   inode's provider is swapped.
3. The dentry lock or the inode lock, taken inside the rebind lock and
   never overlapping each other.

The superblock's identity lock and staging lock are each taken alone.
`copy_file_range` and `remap_file_range` deliberately do not nest the
two files' mutation locks: the input file's is taken, a reference
grabbed, and released before the output file's is taken.

For mutations the ordering is: the KACS decision, then parent
materialisation, then write access on the target mount, then the
parent's directory lock through the VFS's create, remove or rename
helpers. Refusal auditing takes the dentry lock with an atomic
allocation first and falls back to the rebind lock only if that fails,
so the two are never nested.

---

# 4.5.8 Durability and Accounting

_Peios / Advanced Peios / PKM / stratafs / Mutation_

> stratafs holds no storage, so durability and accounting belong to the providers — except where a merged directory needs a rule of its own.

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 descriptors and allocates its inodes bare — who performs which access check, and when.

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_

> An operation on a merged directory is checked against every participating directory — including where the create stratum's directory does not exist.

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_

> A copy-up carries the source's descriptor rather than inheriting a new one — how it is carried, what failure means, and why preservation wins.

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_

> Every stratafs mount carries the FACS policy class that denies access to an object with no readable descriptor, and what follows from that.

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_

> The two stratafs events recorded because the information cannot be recovered afterwards — plus two gaps and what is not audited at all.

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_

> The one synthetic extended attribute that reveals which stratum provided a merged path — its value, constraints and access rules.

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_

> Every condition under which a stratafs operation fails and the error it produces, from mount time through resolution and mutation.

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 `EROFS` arrives at the write, the truncate, or the
  attempt to establish a shared writable mapping (§4.5.1).
- **A copy-up moves an object out from under descriptors already open
  on it.** Only the descriptor whose operation caused the copy-up refers
  to the copy; others continue to refer to the original, and locks held
  on it stay there (§4.5.1, §4.5.7).
- **A write may fail `ESTALE` on a descriptor that was valid when
  opened.** Where another descriptor, another mount, or a direct writer
  has since caused that name to be provided by a different object, a
  copy-up cannot proceed and the caller must reopen (§4.5.2).
- **`fstat` and `stat` may report different inode numbers for one
  file.** After a copy-up, the descriptor that caused it keeps the inode
  it was opened against while a fresh resolution of the path yields a
  new one, so the two disagree for as long as that descriptor lives —
  though both name the copy (§4.4.3).
- **A directory's link count is always 1.** The true count of
  subdirectories spans strata and cannot be maintained (§4.4.3).
- **Directory positions do not survive a reopen.** A `readdir` offset
  is an ordinal into a per-descriptor capture, carrying no provider
  cookie (§4.3.4).

Every one of the first four is the result of refusing to invent a
hidden record — a whiteout — that would make an entry in someone else's
directory unreachable without their knowledge. The alternative buys
POSIX fidelity at the cost of a stratum no longer meaning what its
owner wrote in it, which is the property §4.2.1 exists to protect.

---

# Appendix 4.A Constants

_Peios / Advanced Peios / PKM / stratafs_

> Every stratafs constant — filesystem identity, stratum flags, extended attributes, copy-up and staging markers — generated from the source.

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.o`
- `lookup.o`
- `inode.o`
- `file.o`
- `dir.o`
- `xattr.o`
- `copy_up.o`

---

# 5.1 Overview

_Peios / Advanced Peios / PKM / LCS_

> LCS is the kernel half of the Peios registry — where it sits, its semantic core, what is layered and what is not, and where the model diverges.

LCS — the Layered Configuration Subsystem — is the kernel half of the
Peios registry: a hierarchical, access-controlled configuration store
modelled on the Windows registry. It owns the namespace, the security
model, change observation, transactions, and the layer system that
gives the registry its name. It owns no storage at all.

Storage belongs to **sources**: userspace processes that hold registry
data and answer questions about it over the Registry Source Interface.
A source stores what it is told and returns everything it holds; it
never resolves layers, never filters by visibility, never sees the
identity of a caller, and never interprets a path beyond the parent and
child names it is given. Every decision about what a caller may see or
do is made in the kernel. loregd is the first source, and the one that
provides `Machine\` and `Users\` at boot, but nothing in LCS knows that:
hive routing is built entirely from what registers.

Userspace never talks to a source. Processes reach the registry through
three syscalls and eighteen ioctls, and the fd those syscalls return is
a capability — an open key carries the access mask it was granted, and
carries it wherever the fd goes.

## 5.1.1 Where it sits

LCS is a subsystem of PKM, peer to KACS and KMES, staged into the
kernel tree as `security/pkm/lcs` and built by `CONFIG_SECURITY_PKM` — a
boolean option, so it is linked into `vmlinux` rather than loaded. Its
three syscalls occupy 1100–1102 in the PKM range, added to the syscall
table by a patch against `arch/x86/entry/syscalls/syscall_64.tbl`.

It depends on KACS and KACS does not depend on it. Every access
decision LCS makes is a call into the KACS AccessCheck function against
a Security Descriptor the source returned; LCS defines no access
control mechanism of its own. Security Descriptor inheritance at key
creation is likewise KACS's computation, not LCS's. Audit events go to
KMES.

A substantial part of LCS is Rust. The crate `lcs-core` is staged
alongside PKM's other cores as `security/pkm/lcs/lcs_core` and holds
the parts where correctness is a matter of pure decision rather than
kernel plumbing: layer resolution, the RSI wire codec, the backup
stream serialiser, the transaction mutation log, watch dispatch, case
folding, and configuration validation. The C half owns fds, the char
device, memory, locking, and the syscall boundary.

## 5.1.2 The semantic core

Six rules generate the rest of the model. Every behaviour described in
this chapter is a consequence of one of them.

1. **Names are layered.** A key's presence at a path is per-layer.
   Different layers can name different keys at the same path, and
   removing a layer removes its names.
2. **Values are layered.** Every write is tagged with a layer; the
   effective value is the highest-precedence entry. Tombstones can
   actively mask lower layers.
3. **Key identity is not layered.** A key's GUID, Security Descriptor,
   volatile flag, symlink flag and last write time belong to the key
   object, not to any layer, and are never automatically reverted.
4. **Security is key-bound, not layer-bound.** Modifying a Security
   Descriptor is a permanent change to the key. Removing a layer does
   not revert it. Security policy is operational state, not
   configuration overlay.
5. **Handles are capabilities granted at open.** An open key fd carries
   an access mask computed once, by AccessCheck, at open time. Later
   operations test the mask, not the descriptor. Changing a descriptor
   does not affect an fd that already exists.
6. **Sources persist, the kernel decides meaning.** A source stores
   path entries, key records and value entries and returns all of them.
   Everything else is the kernel's.

The consequence that surprises people most often is the fourth. A
layer is a configuration overlay and reverts cleanly; an access control
change is not configuration and does not.

## 5.1.3 What is layered and what is not

| 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 the first component of every path — the routing table that maps hive names to sources, and the two names the kernel knows itself.

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_

> Hives invisible in the global namespace, reachable only through a scope GUID on the calling token — routing, lifecycle and registration rules.

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_PRIVATE` flag 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 returned `ENOENT`.
- Unknown flag bits are rejected.
- Opening `/dev/pkm_registry` at all requires an **enabled**
  `SeTcbPrivilege`, checked in the device's `open()` handler
  (§5.8.2).

---

# 5.2.3 Keys

_Peios / Advanced Peios / PKM / LCS / The Data Model_

> A key is a container of subkeys and values — its identity, its properties, volatile keys, and the rules for naming one.

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_

> The two load-bearing mechanisms behind a symlink key — the structural flag and the target value — plus resolution, depth and opening the link itself.

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_KEY` on the parent, as for any key;
- `KEY_CREATE_LINK` on the parent;
- either an enabled `SeTcbPrivilege` or membership of Administrators.

The last is a genuine disjunction — either satisfies it — and the
privilege branch marks the privilege used. Failing it is `EPERM`.

---

# 5.2.5 Path Entries

_Peios / Advanced Peios / PKM / LCS / The Data Model_

> Key existence and naming are layer-qualified while key identity is not — the per-layer path entry that separates them, and hiding.

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 named, typed datum inside a key — one name with many layer entries, the type set, REG_TOMBSTONE, naming and size.

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 — value and blanket tombstones, how they compete, and what a watcher sees.

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 is UTF-8 — length as byte count, separators, and how case folding works.

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 works on two levels because naming and identity are separate — layer deletion, orphaned keys, and what happens to their watches.

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 named collection of registry writes managed as a unit — precedence tiers, caps, and why layers are global while entries are per-source.

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 kernel-reserved layer that exists unconditionally before any source registers — its persisted metadata and the two ways it is matched.

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 it configures — the circularity that creates, why publication is atomic, and when the refresh runs.

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 mutation targeting a layer requires layer write access — the base layer before it exists, the precedence gate, and deleting a layer.

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 disabled layer attached to a thread's credentials — how it resolves, how it is attached, and the privilege that is deliberately not checked.

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_

> Turning several per-layer entries into one effective answer — the rule, why unknown layers are latent rather than wrong, and enumeration.

Layer resolution turns several per-layer entries into one effective
answer. It is the mechanism that makes the registry layered, and it is
the same algorithm for path entries and for values — one resolves
existence claims, the other resolves data.

## 5.3.6.1 The rule

Every candidate is a tuple `(precedence, sequence, entry)`. The winner
is the maximum, ordered by precedence first and sequence second.

Building the candidate list:

1. For each entry the source returned, look up its layer in the current
   layer table. An entry naming a layer that is not in the table is
   **skipped**, not rejected.
2. Discard entries from layers that are not active for this thread — a
   layer is active if it is globally enabled or its name is in the
   thread's private layer set (§5.3.5).
3. For values, add every blanket tombstone on the key as a tombstone
   candidate for the requested name, at its own precedence and
   sequence (§5.2.7).
4. If there are no candidates, the answer is not-found.
5. Take the maximum. A winning HIDDEN path entry, a winning
   `REG_TOMBSTONE` value entry, and a winning blanket all mean
   not-found.

That is the whole algorithm. Everything the layer system does —
override, revert, mask, hide, hide-and-replace — is a consequence of
it.

## 5.3.6.2 Unknown layers are latent, not wrong

An entry tagged with a well-formed layer name that is not currently in
the table is a valid **latent** entry. It is ignored while the layer is
absent, and if a layer with the same folded identity is later created,
that entry becomes eligible for resolution under the new metadata.

This is what makes restore, import and boot ordering work: source
storage can legitimately hold entries before their metadata has been
loaded. It also follows the core rule — sources persist entries, LCS
decides their meaning.

Normal operations do not create such entries. A layer-targeting ioctl
naming a layer that is not in the table returns `ENOENT`. Latent
entries come from existing storage, from a restore or import, from a
previous boot, or from source behaviour. Since sources are trusted,
a well-formed latent entry is not malformed merely because its layer is
absent right now.

An entry whose layer *name* is malformed is a different matter, and is
rejected as malformed source data.

## 5.3.6.3 Enumeration

Enumerating values collects the unique names across all layers and
resolves each one, returning only those whose answer is not not-found.
Enumerating subkeys collects the unique child names and resolves each,
returning those that map to a GUID rather than HIDDEN.

"Unique" means folded-unique. Two entries whose names differ only in
case are one name, which is the only reading consistent with names
being case-insensitive.

A caller sees effective state only. Tombstoned values, blanket-masked
values and hidden keys are simply absent. Per-layer raw data is never
visible through a normal operation.

## 5.3.6.4 Enumeration is index-based, and that has a cost

`REG_IOC_ENUM_VALUES` and `REG_IOC_ENUM_SUBKEYS` return one entry at an
index. Each call re-resolves the full set and returns the entry at that
position, so walking `0..N-1` performs N full resolutions — O(n²) work
overall.

For a key with a handful of values that does not matter.
`REG_IOC_QUERY_VALUES_BATCH` exists for when it does: one call, the
whole effective value set, one resolution.

Enumeration order is not defined, and the index-to-entry mapping can
change between calls if the effective set changes. Indices must not be
cached across mutations. The batch call is also the way to get a
consistent snapshot.

## 5.3.6.5 Neither party can lie about ordering

Two rules stop a source manipulating the outcome.

A source-returned entry whose sequence number is greater than or equal
to the next sequence LCS would allocate is **malformed data**. Sources
store the numbers LCS assigns them; they cannot legitimately hold a
future one. Without this, a compromised source could fabricate a
sequence number and win every tie in its own hive. Every entry in a
response is validated this way, whatever layer it names.

Duplicate sequence numbers at the same precedence, where they would
actually have to be compared to pick a winner, are also malformed. LCS
rejects the response rather than making an arbitrary choice. Duplicates
that never get compared are not an error.

Both produce `EIO` and an `LCS_SOURCE_VALIDATION_FAILURE` audit event
(§5.4.4).

---

# 5.3.7 The Sequence Counter

_Peios / Advanced Peios / PKM / LCS / Layers_

> The single global monotonic counter stamped on every layer-qualified entry — allocation, initialisation, and what it is not.

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 — no traverse checking, symlink handling, and why an fd is a capability.

The registry's security model is KACS applied to keys. LCS defines no
access control mechanism of its own: the same AccessCheck, the same
Security Descriptors, the same tokens and SIDs that every other Peios
subsystem uses. What this section describes is how those primitives
attach to registry operations.

Every open follows the same five steps.

1. **Token capture.** LCS takes the calling thread's effective token —
   the impersonation token if one is set, otherwise the process primary
   token. This is the same capture KACS performs for every syscall.

2. **Path resolution.** LCS walks the path through the layer stack,
   following symlinks. **No access check happens during the walk.**
   Intermediate keys are not evaluated; only the final key matters.

3. **AccessCheck.** LCS calls KACS AccessCheck with the captured token,
   the final key's Security Descriptor as returned by the source, and
   the desired access mask. Every requested right must be granted or
   the open fails with `EACCES`. There is no partial grant: the caller
   gets what it asked for or nothing.

   `MAXIMUM_ALLOWED` is the exception, and the only way to ask for
   whatever is available. AccessCheck computes the full allowed set and
   that becomes the granted mask.

4. **The granted mask is stored on the fd.** It never changes.

5. **Per-ioctl checks are bitmask tests.** Each ioctl has a required
   right; LCS tests the fd's granted mask against it and returns
   `EACCES` without contacting the source if it is absent. The
   Security Descriptor is not re-read and AccessCheck is not
   re-evaluated.

## 5.4.1.1 No traverse checking

LCS checks nothing on the way down. A process can open
`Machine\System\Services\Jellyfin` without holding any access to
`Machine\System\Services` or `Machine\System`.

This matches the Windows registry and is a deliberate difference from
filesystem path semantics, where every directory in a path is checked
for traverse. It means a key's Security Descriptor is the whole story
about who can reach it, and that an ancestor's descriptor confers no
protection on its descendants.

## 5.4.1.2 Symlinks

Opening a symlink follows it, and AccessCheck runs on the **target**
key, not the link. `REG_OPEN_LINK` opens the link itself instead — but
only for the final path component. A symlink encountered part-way
through a path is followed regardless.

Creating a symlink is privileged: `KEY_CREATE_SUB_KEY` and
`KEY_CREATE_LINK` on the parent, plus either `SeTcbPrivilege` or
membership of Administrators (§5.2.4).

## 5.4.1.3 Fds are capabilities

A key fd can be passed over a Unix socket with `SCM_RIGHTS`, and it
carries its granted mask with it. The recipient gets the access the
original opener was granted, whether or not its own token would have
passed AccessCheck.

This is explicit delegation and it is consistent with how fds work
everywhere else in Peios. Passing a `KEY_WRITE` fd hands over write
access.

Opening relative to a parent fd skips path parsing and AccessCheck for
the parent portion — the caller already proved its access when it
obtained the parent fd. This is the ordinary way to traverse a subtree.

## 5.4.1.4 Changing a descriptor does not revoke a handle

A Security Descriptor change takes effect for future opens. An fd that
already exists keeps the mask it was granted at open, because that is
what semantic rule 5 says (§5.1). The recourse for genuinely revoking
access is to restart the process holding the fd.

Descriptor changes are also not layer-qualified. They are direct
mutations on the key object, and deleting a layer does not undo one
(§5.1, rule 4).

## 5.4.1.5 The second check nobody expects

Every layer-qualified mutation runs a **second** AccessCheck, against a
different object: the metadata key of the layer being written to. That
check is for `KEY_SET_VALUE` and it is in addition to the fd's granted
mask on the target key. Both must pass. §5.3.4 covers it.

---

# 5.4.2 Access Rights

_Peios / Advanced Peios / PKM / LCS / Security_

> Registry rights at their Windows bit positions — the convenience masks, generic mapping, and validation of what a caller asks and a source returns.

Registry rights occupy the Windows bit positions, so a Security
Descriptor carrying registry ACEs is binary-compatible with one written
by Windows tooling. That is a `registry.pol` and Samba requirement, not
an aesthetic choice.

The values are in §5.A. In summary: six specific rights in bits 0–5
(`KEY_QUERY_VALUE`, `KEY_SET_VALUE`, `KEY_CREATE_SUB_KEY`,
`KEY_ENUMERATE_SUB_KEYS`, `KEY_NOTIFY`, `KEY_CREATE_LINK`), the four
standard rights `DELETE`, `READ_CONTROL`, `WRITE_DAC` and
`WRITE_OWNER`, and `ACCESS_SYSTEM_SECURITY` for the SACL, which is
itself gated by `SeSecurityPrivilege`.

There is no execute right on a key.

## 5.4.2.1 Convenience masks and generic mapping

`KEY_READ`, `KEY_WRITE` and `KEY_ALL_ACCESS` are concrete masks, not
generic bits — they are unions of the rights above and are usable
directly.

Separately, LCS accepts the raw KACS generic bits `GENERIC_READ`,
`GENERIC_WRITE`, `GENERIC_EXECUTE` and `GENERIC_ALL` in a caller's
`desired_access` and in ACE masks in a Security Descriptor. Those are
mapped through the registry generic mapping before AccessCheck sees
them. `GENERIC_READ` maps to `KEY_READ`, `GENERIC_WRITE` to
`KEY_WRITE`, `GENERIC_ALL` to `KEY_ALL_ACCESS`, and **`GENERIC_EXECUTE`
maps to zero**, because there is nothing to execute.

## 5.4.2.2 Validating what a caller asks for

`desired_access` is validated before path resolution or AccessCheck.

- Zero is `EINVAL`. A caller must ask for something.
- Any bit outside the valid caller mask is `EINVAL`.
- `MAXIMUM_ALLOWED` may appear alone or combined with anything else.
- `SYNCHRONIZE` (`0x00100000`) is not a registry right, so it is an
  unknown bit and fails `EINVAL`.

The valid caller mask is a single constant, `REG_VALID_DESIRED_ACCESS_MASK`
(§5.A): the six specific rights, the four standard rights,
`ACCESS_SYSTEM_SECURITY`, `MAXIMUM_ALLOWED` and the four generic bits.

## 5.4.2.3 Validating what a source returns

Source-supplied Security Descriptors are validated too, because a
source is trusted for its data but not for its arithmetic (§5.8.4).

An ACE mask may contain concrete registry rights and raw generic bits.
It **may not** contain `MAXIMUM_ALLOWED` — that is a request, not a
grant, and it is meaningless in an ACE. After generic mapping, an ACE
mask must be a subset of the concrete registry rights plus
`ACCESS_SYSTEM_SECURITY`.

A descriptor that breaks either rule is malformed source data: the
operation fails closed with `EIO` and an
`LCS_SOURCE_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_

> A new key's descriptor is computed from its parent by the KACS inheritance algorithm — plus hive roots and reading or writing a descriptor.

## 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_

> The seven audit events LCS emits, which are unconditional, and what happens when emission itself fails.

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 with `EIO` and no key fd is published. If the
  payload is valid but KMES cannot *retain* the event — unavailable,
  ring drops, capacity pressure, no consumer — the access decision and
  the fd publication are unaffected. Loss accounting is KMES's problem.
- **`LCS_BACKUP_START`, `LCS_RESTORE_START`.** Emission failure returns
  `EIO` and 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 with `EIO`. 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_

> The hybrid syscall and ioctl interface — key fds, open-time checking, transaction fds, and the conventions for strings and output buffers.

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_

> The three LCS syscalls — reg_open_key, reg_create_key and reg_begin_transaction — with their arguments and failure conditions.

## 5.5.2.1 `reg_open_key` (1100)

```c
int reg_open_key(int parent_fd, const char __user *path,
                 u32 desired_access, u32 flags);
```

Opens an existing key. Fails if it does not exist after layer
resolution.

| 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.

1. Parse and canonicalise the path: normalise separators, reject empty
   components and a trailing separator, check the total length and each
   component length.
2. Rewrite a leading `CurrentUser\` to `Users\<caller SID>\` — only for
   an absolute path, and only the first component (§5.2.1).
3. Route. For an absolute path, look the hive name up, private hives
   before global ones. For a relative one, use the parent key's source
   and resolve from the parent's GUID.
4. Walk the path component by component through `RSI_LOOKUP`, resolving
   each through the layer stack and following symlinks — except a final
   component when `REG_OPEN_LINK` is set. Collect the ancestor chain as
   you go.
5. Run AccessCheck against the final key's descriptor.
6. Publish an fd holding the key GUID, the granted mask, the resolved
   path and the ancestor chain.

If the path traversed a symlink, the fd stores the *resolved* path and
ancestor chain. It refers to the target object.

| 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)

```c
int reg_create_key(const struct reg_create_key_args __user *args);
```

Opens the key if it exists after layer resolution, creates it with an
inherited descriptor if not, and reports which happened.

| 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:**

1. Resolve the parent, which must exist. Check that the parent's depth
   plus one is within `MaxKeyDepth`.
2. AccessCheck the parent for `KEY_CREATE_SUB_KEY`.
3. Perform layer write authorization against the target layer's
   metadata key (§5.3.4).
4. Mint a fresh UUIDv4 GUID.
5. Compute the new key's descriptor from the parent's, through KACS.
6. Create the path entry with a new sequence number, then the key
   record (§5.2.5).
7. AccessCheck the *new* key's inherited descriptor against
   `desired_access` — an inherited descriptor may not grant everything
   the creator asked for.
8. Publish the fd with the granted mask and disposition
   `REG_CREATED_NEW`.

Intermediate path components are not auto-created. Only the final one
is.

**Races.** If two callers race, one creates and the other observes the
key as existing. `RSI_ALREADY_EXISTS` on the path entry is retried as
an open, reporting `REG_OPENED_EXISTING`, and `EEXIST` never reaches
userspace from this syscall. `RSI_ALREADY_EXISTS` on the *key record*,
for a GUID LCS has just minted, is not a race — the source and the
kernel disagree about what exists — and fails closed with `EIO`
(§5.2.3).

| 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)

```c
int reg_begin_transaction(void);
```

Allocates a transaction id, publishes a transaction fd in state
`REG_TXN_ACTIVE_UNBOUND`, and starts the lifetime timer. It contacts no
source and chooses none (§5.7.1). It can fail only with `ENOMEM`,
`EOVERFLOW` on transaction id exhaustion, or `EINVAL`.

---

# 5.5.3 Key Ioctls

_Peios / Advanced Peios / PKM / LCS / The Syscall Interface_

> The sixteen ioctls acting on a key fd — reading, writing, security, watches, durability and bulk — each gated on the fd's granted mask.

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_

> The two ioctls acting on a transaction fd — commit and status — and why the key fd error table does not apply.

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_

> How LCS reports failure, and the two errnos worth reading closely because they mean less than they appear to.

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 persistent subscription to changes on an open key, following inotify rather than the Windows model — what it observes, its event types and filters.

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 read from the key fd with read() — the record layout, why not every subtree record carries a path, and forward compatibility.

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_

> Given a mutation, which armed watches hear about it — computed from object identity and ancestry, with depth, batching and recovery.

Dispatch answers one question: given a mutation on a key, which armed
watches hear about it? The answer is computed from object identity and
from ancestry captured at open time, never from a path string resolved
at the moment of the event.

## 5.6.3.1 A watch is bound to an object

A watch is registered against the GUID on the fd it was armed through.
It fires for changes to *that object*. If a layer change causes a
different key to become visible at the same path, the watch does not
move: the new key is a different object with a different GUID, and
nothing about the old watch refers to a name.

To follow the path rather than the object, a process detects the change
— through `KEY_DELETED` on the old object, or a subtree watch on the
parent — re-opens the path, and arms a new watch. This is the fd model
applied consistently: an fd is a capability bound to one identity.

The converse also holds. If a watched key is hidden, `KEY_DELETED` is
delivered, but the watch is not removed. Should the hiding layer later
be deleted, the key reappears at its original path with the same GUID,
and events resume. There is no re-emergence event; the watcher simply
observes the key being active again.

## 5.6.3.2 The ancestor chain

Subtree dispatch needs to know where a key sits in the tree, and it
learns that once, at open.

Resolving a path walks it component by component, and the GUID at each
level is retained on the resulting fd as the **ancestor chain** —
root GUID through parent GUID to the key itself. It costs nothing
extra: the walk had to resolve those components anyway. A relative open
copies the parent fd's chain and extends it with the components the
relative walk resolved. An open that followed a symlink records the
chain of the *resolved* path, so the fd's ancestry reflects the target's
real position in the tree, not the link's.

Dispatch uses that captured chain and never re-resolves it. If an
ancestor in the chain is later hidden, deleted, orphaned, or replaced
at the same path by a different key, watches on the original ancestor
GUID still receive subtree events from descendants mutated through fds
opened through that chain. Watches on a new key that later appears at
the same path receive nothing from the old one.

## 5.6.3.3 The algorithm

Two structures back dispatch. The **watch map** is a hash map from GUID
to the watchers armed on it. The **subtree watch set** is a refcounted
hash set of the GUIDs that have at least one subtree watch, maintained
as watches are armed, re-armed, disarmed and closed.

For a mutation on key *B*, with *B*'s ancestor chain in hand:

1. Look up *B*'s GUID in the watch map and queue the event, with
   `path_depth` 0, to every watcher whose filter admits it.
2. Walk the ancestor chain outward from *B*'s parent. At each ancestor,
   skip it unless it is in the subtree watch set — that test is what
   makes the walk cheap. For an ancestor that is, queue the event to
   each of its watchers that armed with the subtree flag and whose
   filter admits it, with the path from that ancestor down to *B*.

The cost is O(depth) hash lookups, with no RSI round trips, no trie and
no string comparison. The path components handed to a subtree watcher
are sliced out of the resolved path already stored on the mutating fd.

Dispatch runs under a single global registry lock, so it is serialised
across the whole system rather than per hive or per source.

## 5.6.3.4 Depth

`MaxSubtreeWatchDepth` bounds how far below a watched key a subtree
watch is told about. The default is 0, meaning unlimited. A non-zero
value suppresses events whose path is longer than it, limiting both
noise and dispatch cost for a watch armed high in a tree.

The limit applies to watches held by userspace. LCS's own internal
watches are collected before the depth test and are not subject to it
(§5.10.4).

## 5.6.3.5 Transaction batches

Nothing inside an uncommitted transaction dispatches. At commit, and
only when the source reports success, the transaction's mutation log is
walked in operation order and the whole set of events is queued as one
batch, under a single hold of the registry lock, so no other operation
interleaves with it. An aborted, 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 bounded queue and delivery is best-effort — what a full queue does, and what a watcher does about it.

Each armed fd has its own event queue, bounded by
`NotificationQueueSize` — default 256 events, configurable between 16
and 65536 (§5.10.3).

Delivery is best-effort. A watcher that reads promptly sees every
change; one that falls behind is told that it has, rather than being
given a partial history it cannot distinguish from a complete one.

## 5.6.4.1 What happens when the queue is full

When an event arrives for a full queue and no `OVERFLOW` is present:

1. The oldest queued event is dropped.
2. An `OVERFLOW` record is queued in its place.
3. The event that triggered this is **discarded**, not queued.

Once an `OVERFLOW` is in the queue, subsequent events queue normally:
the oldest non-`OVERFLOW` event is dropped to make room and the new
event is added, so the single `OVERFLOW` is preserved and the queue
continues to carry the most recent history behind it.

A queue therefore holds at most one `OVERFLOW` at a time, and that is
an enforced invariant rather than a convention — an attempt to queue a
second is rejected as a bug.

## 5.6.4.2 What a watcher does about it

`OVERFLOW` means the record it has is incomplete. There is no way to
learn what was dropped, and no attempt is made to describe it. The
watcher re-reads the watched key, and its subtree if the watch is a
subtree watch, and continues from the state it finds. Events after the
`OVERFLOW` are complete again.

`REG_IOC_QUERY_KEY_INFO` reports a per-hive generation number
(§5.5.3) that makes this cheaper than it sounds: a watcher that
recorded the generation at its last full read can compare it with the
current one and skip the re-read entirely if nothing committed in
between.

## 5.6.4.3 Memory

There is no registry-specific global cap on watch memory, and none is
needed. A watch costs at most `NotificationQueueSize` queued events, a
watch requires an fd, and a process holds at most `RLIMIT_NOFILE` fds.
The product of the two is the bound, and it is enforced by machinery
that already exists.

The same reasoning covers open key state: per-fd overhead — GUID,
granted mask, ancestor chain, watch state — multiplied by the fd limit.

LCS's own internal watches are outside this. They are delivered
synchronously through a kernel callback rather than queued, so the
queue limit does not apply to them (§5.10.4).

---

# 5.7.1 Scope and Lifetime

_Peios / Advanced Peios / PKM / LCS / Transactions_

> Grouping registry operations so they commit together or not at all — binding, states, timeout, and sources that do not support them.

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_COMMIT` on the transaction fd. The source
  applies everything atomically, watch events fire, and the object
  becomes `COMMITTED`. Later use of the fd returns `EINVAL`. The fd
  should be closed.
- **Explicit abort.** `close()` without committing. The source is told
  to discard, and the object becomes `ABORTED` during release. No
  events.
- **Implicit abort.** Process death closes the fd, which aborts it.
  There are no orphaned transactions.
- **Timeout.** The lifetime timer fires. See below.

The object stays addressable after reaching a terminal state, until the
fd is closed. That is what makes `REG_IOC_TXN_STATUS` useful.

Transaction fds are pollable. Any terminal transition wakes poll
waiters with `POLLERR | POLLHUP`. A caller that needs a race-free
reason for the wakeup queries the status.

## 5.7.1.5 Timeout

The lifetime timer starts when the fd is created — not at the first
operation — and runs for `TransactionTimeoutMs`, default 30 seconds.

When it fires, the object becomes `TIMED_OUT`, poll waiters are woken,
and further use of the fd returns `ETIMEDOUT`. If the transaction was
bound and no commit is already in flight, `RSI_ABORT_TRANSACTION` is
sent to the source. The fd is not removed from the caller's table;
`close()` still releases it normally.

The timeout is not a convenience. Sources serialise writers — loregd
does so through SQLite's WAL — so a stalled transaction blocks every
other write to that source. The timer is what bounds the starvation
window, and `MaxBoundTransactionsPerSource` (default 16) is what stops
colluding processes from extending it indefinitely by binding fresh
transactions at each timeout boundary. An operation that would bind
past that cap returns `EBUSY`.

The cap is tested before the source is contacted, but after the
transaction's mutation-log entry has been allocated; that entry is
freed on the way out.

## 5.7.1.6 Constraints

- **No nesting.** Transactions are flat: no savepoints, no
  sub-transactions. The transaction fd accepts only `REG_IOC_COMMIT`
  and `REG_IOC_TXN_STATUS`; every other ioctl is `ENOTTY`.
- **One transaction per fd.** A process may hold many transaction fds
  at once, but each is exactly one transaction.
- **Reads are permitted.** A transaction is not write-only, which is
  what makes verify-then-write possible.

---

# 5.7.2 Isolation and the Mutation Log

_Peios / Advanced Peios / PKM / LCS / Transactions_

> Read-your-own-writes inside a transaction, implemented by the source rather than LCS — the mutation log, sequence numbers and conflicts.

## 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_

> What a commit triggers in order, the failures that leave a transaction open, and what happens when the watch events cannot be delivered.

`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 holding registry data and answering LCS over RSI — the trust boundary, and why loregd is not special.

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_

> Reaching LCS through /dev/pkm_registry — what registration validates, how source slots work, and resuming one that went Down.

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_PRIVATE` carries 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 within `MaxRegisteredSources` (32). Either is
  `ENOSPC`.
- The reported maximum sequence can be advanced past without
  overflowing 64 bits, or registration fails `EOVERFLOW` and the source
  is never made Active (§5.3.7).

Root GUIDs are checked for uniqueness within one request, and 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 — request ids and matching, the single deadline covering three waits, and requests with no caller.

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 — malformed data against malformed protocol, and the asymmetric extensibility between them.

LCS validates every response before using it, and the failures split
into two categories with very different consequences.

## 5.8.4.1 Malformed data

The RSI message is structurally valid but its content is not: a
descriptor that will not parse, a value type that does not exist, a
sequence number that cannot be real, a metadata block that does not
cover the GUIDs it should.

- The request returns `EIO` to its caller.
- An `LCS_SOURCE_VALIDATION_FAILURE` audit event is emitted, naming the
  source slot and — where known — the hive, the request id, the
  operation code, the key GUID, and which of the twelve validation
  classes applies (§5.4.4).
- **The source stays alive.** Corruption may be localised, and one bad
  key is not a reason to take a hive offline.

What is checked, by category:

- **Security Descriptors**, from lookups and from layer metadata
  refreshes, must parse and must satisfy the ACE mask rules of §5.4.2.
  A malformed layer metadata descriptor additionally leaves the
  previous known-good one cached (§5.3.3).
- **Names** — layer names, key and child names, value names — must be
  valid under the ordinary rules for their kind.
- **Sequence numbers** must be below the next number LCS would
  allocate, and must not duplicate at the same precedence in a way that
  would decide a winner (§5.3.6).
- **Payload shape** — an otherwise-matched response whose
  operation-specific payload is the wrong shape, carries trailing
  bytes, or encodes a path target invalidly.
- **Metadata closure** — a lookup or enumeration whose per-GUID
  metadata block has missing, duplicate, unreferenced or nil entries.
  A HIDDEN entry must carry an all-zero GUID and contributes no
  metadata.
- **Value payloads** — invalid types, a tombstone carrying data, data
  above `MaxValueSize`.
- **Orphan lists** — a nil or duplicated GUID in an `RSI_DELETE_LAYER`
  response.
- **Status codes** outside the defined vocabulary.

## 5.8.4.2 Malformed protocol

The message itself is structurally invalid: bad framing, a truncated
response, an unknown request id, a duplicate response, an operation
code that does not match the request.

This is treated as a source crash. The connection is torn down, the
in-flight table is destroyed with every waiter completed `EIO`, the
slot is marked Down, its hives become unavailable, and bound
transactions enter `SOURCE_DOWN`.

There is one case where malformed *data* also takes the source down:
when the caller had already timed out and the operation was a commit or
a replayable mutation. At that point LCS cannot establish whether a
mutation was applied, and it cannot account for one it cannot describe
(§5.8.5).

## 5.8.4.3 Asymmetric extensibility

Requests and responses do not extend the same way.

A **request** may carry trailing fields a source does not recognise; a
source skips them using `total_len`. That is how a new optional field
is added without an RSI version bump.

A **response** may not. LCS rejects any trailing bytes in a response
payload as malformed data. Forward compatibility on the response side
comes from new operations, not from extending existing payloads.

---

# 5.8.5 Failure and Late Responses

_Peios / Advanced Peios / PKM / LCS / Sources_

> What happens when a source dies, and the late-response problem — a late error, a late successful read, and a late successful write.

## 5.8.5.1 When a source dies

The connection closes unexpectedly, and:

1. The slot is marked Down and its hives become unavailable.
2. Every pending request fails `EIO`, including ones queued but not yet
   delivered.
3. **Open key fds stay valid.** An fd holds a GUID and a granted mask,
   and neither depends on the source. Operations needing a round trip
   return `EIO` until the source comes back.
4. Bound transactions enter `REG_TXN_SOURCE_DOWN`, their poll waiters
   are woken with `POLLERR | POLLHUP`, their mutation logs are
   released, and further use of those fds returns `EIO`.
5. **Watches stay armed.** Watch state is kernel-side and does not
   depend on the source at all. Nothing is delivered during the window,
   and `OVERFLOW` arrives on re-registration rather than on disconnect
   (§5.6.3).

Coming back is covered in §5.8.2.

## 5.8.5.2 The late response problem

A caller that times out after its request was dispatched is gone, but
the request is not. The source may still apply the operation and answer
minutes later, and by then there is nobody to return a value to — while
the *kernel* still has work to do, because a mutation that succeeded
has to produce its generation increment and its watch events.

This is the most intricate part of LCS and the part with the most ways
to be subtly wrong.

A late response is validated exactly like an on-time one. The rules
that follow apply **only** to a request whose caller was detached after
its deadline, never to one that never had a caller (§5.8.3).

### 5.8.5.2.1 A late error

The record is released. No watch events, no generation change, nothing.

### 5.8.5.2.2 A late successful read

Validated, then discarded. There is nobody to give it to and nothing
about it changes kernel state.

### 5.8.5.2.3 A late successful mutation

The kernel-side effects that correspond to the mutation are applied
from the retained record: the hive generation increment, watch
dispatch, the layer metadata cache refresh, and — for a commit — the
transaction's batch effects and orphan tracking.

Which mutations can actually be replayed is narrower than the set of
operations that count as mutating. A replayable effect is recorded for
`RSI_SET_VALUE` and `RSI_WRITE_KEY`, and only for non-transactional
calls. For the other mutating operations — creating, hiding or deleting
a path entry, creating or dropping a key, deleting a value entry,
setting a blanket tombstone, deleting a layer — no effect was retained,
so a late success is a mutation the kernel cannot account for. LCS
tears the source down and returns `EIO` rather than silently ignoring
it.

That is the conservative direction, and deliberately so: the
alternative is a committed change with no watch event and a stale
generation number, which nothing downstream could detect.

### 5.8.5.2.4 A late successful transaction operation

A late `RSI_BEGIN_TRANSACTION` has created transaction state in the
source that nobody will ever use, so LCS enqueues an
`RSI_ABORT_TRANSACTION` for that id.

A late `RSI_ABORT_TRANSACTION` or `RSI_FLUSH` releases the record with
no effects.

A late `RSI_COMMIT_TRANSACTION` is a mutating response and applies the
retained commit effects — the same generation update and the same watch
events an on-time commit would have produced. Watchers may therefore
observe the effects of a transaction whose caller was told it timed
out (§5.7.3).

### 5.8.5.2.5 A malformed late response

The ordinary malformed-data and malformed-protocol rules apply. But if
LCS cannot safely process the kernel-side effects of a
possibly-applied mutation because the request metadata it needs is
missing or invalid, it tears the source down and marks it Down rather
than ignoring the response. A malformed late commit response takes the
source down for the same reason.

## 5.8.5.3 What a caller should conclude

`ETIMEDOUT` means *may or may not have completed*. A caller that needs
certainty reads state back before retrying. That applies to every
operation and, especially, to a transaction commit.

## 5.8.5.4 Fd lifecycle

Key fds and transaction fds are ordinary file descriptors, subject to
`RLIMIT_NOFILE`. There is no registry-specific fd accounting and no
registry-specific leak protection.

Process exit closes them through normal kernel cleanup: key fds
released and their watches removed, transactions aborted.

## 5.8.5.5 Memory

Registry kernel memory needs no global cap, because everything is
bounded by limits that already exist.

- **Watch queues:** `NotificationQueueSize` per queue, times
  `RLIMIT_NOFILE` queues per process.
- **Open key state:** per-fd overhead — GUID, granted mask, ancestor
  chain, watch state — times the same fd limit.
- **Layer table:** bounded by `MaxTotalLayers`, and per-value
  resolution cost by `MaxLayersPerValue`.
- **In-flight requests:** bounded per source by
  `MaxConcurrentRSIRequests`, and separately by the number of threads
  blocked on registry syscalls.

---

# 5.9.1 The Stream

_Peios / Advanced Peios / PKM / LCS / Backup and Restore_

> The streamable binary representation of a key and everything beneath it with full layer fidelity — what the design buys, and how it is versioned.

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_

> Writing a subtree to a stream under SeBackupPrivilege with no per-key access check — the snapshot, the audit, and what is written.

`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_

> Restore is a replace rather than a merge — one transaction, the surviving target key, the order of operations, and the precedence gate.

`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

1. Validate the whole stream, including the trailer's record count and
   checksum, and retain every replayable record.
2. Read the layer manifest and apply the precedence gate (below).
3. Verify the root record and its immutable flags against the live
   target.
4. Tear down the target's contents and descendants — path entries,
   values, blanket tombstones, and descendant key records — inside the
   transaction.
5. Write the root's mutable fields, then replay its section's values
   and blanket tombstones.
6. For each non-root key in stream order: create it, write its last
   write time, then replay its path entries, values and blanket
   tombstones.
7. Commit.

The stream is read from the fd exactly once, sequentially. Nothing
seeks, so a pipe is a valid input.

Validating the whole stream first is stronger than the format requires:
a checksum failure aborts before any source mutation rather than after
some. The cost is memory rather than seeking — the replayable records
are retained in kernel memory across the teardown.

## 5.9.3.4 The precedence gate

Before any key record is written, LCS checks the layer manifest. If any
declared layer has a precedence above 0, **or** any existing cached
layer table entry with the same folded identity does, and the caller
does not hold `SeTcbPrivilege`, the restore aborts with `EPERM` before
a single byte reaches the source.

And if the stream contains ordinary key and value records for
`Machine\System\Registry\Layers\<Name>\`, writes that create or raise
persisted metadata above precedence 0 hit the ordinary inline
`SeTcbPrivilege` check as well (§5.3.4).

Both exist so that `SeRestorePrivilege` cannot be used to smuggle a
Group Policy-tier layer past the defence in depth that guards
precedence.

## 5.9.3.5 Layers in a restored stream

Manifest records create, update, delete, enable, disable and authorise
nothing. If restored entries reference a layer that is not in the
current table, and the stream does not also restore that layer's
metadata subtree as ordinary registry data, those entries become latent
unknown-layer entries and are ignored during resolution until real
metadata exists (§5.3.6). If the metadata subtree *is* included, it is
restored through the ordinary path, and those records — not the
manifest — are what define the layer.

## 5.9.3.6 Sequence remapping

Backup sequence numbers preserve the backup's internal ordering, but a
restore is a new mutation and its entries must outrank everything
already present. So the numbers are remapped rather than written
through.

Before dispatching the first layer-qualified record, LCS takes the
global sequence-allocation gate and records the current next sequence
as the offset. Every restored record is then written with
`offset + backup_sequence`, which preserves relative order while
placing the whole set above pre-restore state.

The gate is held until the restore reaches a terminal state. Other
sequence-allocating mutations wait; reads are not blocked. A restore
with no layer-qualified records at all never takes it.

If a remapped value would overflow, the restore fails `EOVERFLOW` — and
it fails at validation, before teardown, rather than part-way through.
The valid remapped range stops just below `U64_MAX`, which is never
handed out (§5.3.7).

When the restore reaches any terminal state — commit, abort, failure or
cancellation — LCS advances the global counter past the highest number
it dispatched, and **does not roll that back**. Sequence numbers a
failed restore dispatched become unused gaps, exactly like those of any
other failed write.

## 5.9.3.7 GUID collisions

A GUID that appears twice in one stream, other than the root, is
`EINVAL`, 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 has to configure itself — the three circular dependencies, and the three rules that break them.

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_

> What happens from PKM initialisation to a registered source, on a normal boot and on a first boot, and the contract it establishes.

## 5.10.2.1 Normal boot

```
Kernel boots
  → PKM initialises; LCS runs on compiled-in defaults
  → /dev/pkm_registry registered
  → the base layer exists in memory

A source registers — loregd, started by peinit
  → opens /dev/pkm_registry (SeTcbPrivilege checked at open)
  → REG_SRC_REGISTER: hive names, root GUIDs, max persisted sequence
  → LCS initialises or advances next_sequence to max + 1

Bootstrap refresh is queued
  → resolve Machine\System\Registry     → read and hot-swap parameters
  → resolve Machine\System\Registry\Layers → populate the layer table
  → arm internal subtree watches
```

The bootstrap refresh runs on a workqueue after `REG_SRC_REGISTER`
returns, so registration never blocks on configuration and a source
that registers can start answering immediately.

The refresh is triggered by the arrival of a **global hive named
`Machine`**. That name is matched case-insensitively in the kernel and
is one of the two hive names LCS knows about; the other is `Users`, the
target of `CurrentUser\` rewriting (§5.2.1). Neither is a routing
decision — routing is entirely dynamic — but the claim that the kernel
holds no hive names at all would not be true.

## 5.10.2.2 First boot

An empty source has no `Machine\System\Registry` to read.

```
Source detects an empty database on first startup
  → generates root GUIDs for the hives it backs
  → creates root key records with their default SDs
  → persists them, then registers with LCS

LCS resolves Machine\System\Registry → does not exist
  → compiled-in defaults retained
  → subtree watch armed on the Machine\ hive root instead

peinit notices the registry is empty and restores the seed backup.
That is peinit's decision, not LCS's.

Seed restore populates Machine\ through REG_IOC_RESTORE
  → the fallback subtree watch fires
  → LCS re-runs the bootstrap refresh: resolves the specific GUIDs,
    re-arms targeted watches, validates and hot-swaps the seed values,
    and re-reads layer metadata
```

Hive root Security Descriptors are set by the source, not by LCS. LCS
holds no template for them and enforces whatever the source stores
(§5.4.3).

## 5.10.2.3 Watch arming in practice

The spec-level description above says the fallback watch is armed
*instead of* the targeted ones. What the kernel actually arms is a
**mixed** set: targeted watches on whichever of the two roots exist,
*plus* the `Machine\` root fallback. The fallback is a superset rather
than a substitute, and it stays armed until a refresh finds both roots
present.

A third internal watch is armed by the same sequence, on
`Machine\System\KMES`, which is not LCS configuration at all — it is
how KMES picks up its own parameters from the registry LCS serves.

## 5.10.2.4 The bootstrap contract

Four properties hold throughout and are relied on elsewhere:

1. **LCS is always operational with compiled-in defaults.** It accepts
   syscalls from the moment PKM initialises.
2. **The base layer requires no persisted state.**
3. **Hot-swap is the only configuration transition.** LCS never blocks,
   restarts, or re-initialises.
4. **Source dependencies are the source's concern.**

---

# 5.10.3 Operational Parameters

_Peios / Advanced Peios / PKM / LCS / Bootstrap and Self-Configuration_

> The nineteen parameters LCS reads from the registry, how they are validated, and what hot-swapping one does to in-flight operations.

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_INVALID` audit
  event is emitted naming the parameter, what was wrong, and the value
  being retained (§5.4.4).

**Values are never clamped or silently corrected.** There is no
`min`/`max` on any configuration path. A write to the registry
succeeds, because the source does not enforce kernel semantics, and LCS
simply refuses to use it. The registry shows what was written; the
audit log shows what LCS is running on.

Because "missing" is invalid, a first boot before seed restore emits
nineteen of these events per refresh.

## 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 through the same machinery userspace uses — what it drives, how it is armed, and why the callback is not the atomicity boundary.

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

1. A source registers. LCS reads `Machine\System\Registry\*`; the keys
   do not exist; compiled-in defaults are retained.
2. Seed restore populates them. The subtree watch fires, LCS validates
   and hot-swaps to the seed values.
3. Subsequent administrative changes fire the watch again, and LCS
   validates and hot-swaps, or rejects with an audit event.

At no point is there a state in which LCS is waiting for
configuration.

---

# Appendix 5.A LCS ABI Reference

_Peios / Advanced Peios / PKM / LCS_

> Every LCS syscall number, ioctl, structure layout and constant, generated from the uapi headers and measured by compilation.

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_

> What the LCS ABI tables cannot say for themselves — which properties of the interface are documented with their operations rather than here, and the kernel configuration LCS is built by.

§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 and the only service manager on a Peios system — what makes it not systemd, the shape of the daemon, and what it is not.

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_

> The scope of this manual, what in peinit is a contract and what is not, and where the constants and registry keys live.

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 `svctl` command
  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_

> The terms this manual uses without redefining them, and where each is introduced.

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 of anything — where it meets sd_notify, the fd store, calendar expressions and the Windows SCM, and what it deliberately omits.

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 already assembled — what the handoff guarantees, and everything that stays outside it.

peinit starts with the root filesystem already assembled. Everything
that makes a root mountable — LUKS decryption, LVM activation, RAID
assembly, any filesystem check — belongs to the initramfs and has
happened before peinit exists. peinit performs no root assembly, no
decryption, no repair, and no fsck, and it does not assume one has been
done.

## 2.1.1 The handoff

The initramfs transfers control by `chroot`-ing into the assembled root
and exec'ing peinit there. Not `switch_root`, and not `pivot_root`: the
kernel refuses to relocate onto the initramfs rootfs, so that route is
closed. The consequence is that the initramfs rootfs does not go away.
It remains the mount-namespace root — emptied, and unreachable from
peinit's view, but present. peinit therefore never assumes a clean
single-root mount topology, and never attempts `pivot_root`.

peinit is installed in package storage at `/usr/bin/peinit2` and reached
through the fixed runtime path `/bin/peinit2`. The boot-image tooling
sets the kernel `init=` to the runtime path. Before the transfer, the
initramfs has assembled the base StrataFS topology, including the `/bin`
and `/sbin` views, because peinit reaches every binary it execs through
them.

At handoff:

- the real root is mounted **read-write** at `/`;
- `/proc`, `/sys` and `/dev` are mounted and have been moved into the
  real root;
- the environment holds `TERM` and nothing else, and argv is just
  peinit's own path.

The read-write requirement is registryd's, not peinit's. loregd's
storage backend needs to write its write-ahead log and shared-memory
files even to answer a read, so a read-only root cannot support Phase 2
at all. Delivering the root writable is the initramfs's job; peinit only
confirms it.

peinit inherits nothing from that environment. It does not rely on
having been given anything, and it does not pass its own near-empty
startup environment through to services — the environment a service
receives is constructed from scratch (§5.5).

## 2.1.2 What stays outside

Non-root storage is not peinit's concern. Data partitions and additional
filesystems are mounted at the services layer, typically by a Oneshot
service that runs `mount`. peinit has no mount feature beyond the fixed
Phase 1 set.

> [!NOTE]
> The initramfs itself is assembled by mkirf from hook scripts that
> packages contribute; mkirf resolves the ordering and bakes the
> sequence its PID 1 runs. What peinit depends on is the handoff
> contract above, not how the initramfs arranged to satisfy it.

---

# 2.2 Bootstrap Identity

_Peios / Advanced Peios / peinit / Boot_

> The steady-state identity flow cannot start the system, so platform services run as SYSTEM until authd exists.

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`.

> [!NOTE]
> This is the same arrangement Windows uses: the SCM, LSASS and the core
> platform services all run as LocalSystem. The trust anchor is that
> peinit *is* SYSTEM, and the kernel guarantees that by handing PID 1
> the boot token.

## 2.2.2 After authd

Once authd 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_

> The compiled-in phase with no registry dependency, whose whole purpose is to reach the point where a registry can serve.

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.

> [!NOTE]
> NTP corrects the clock properly later in the boot. This step only gets
> it into the right decade, so that early timestamps are not absurd and
> the timer subsystem has a wall clock to anchor to.

## 2.3.6 Step 6: Start registryd

peinit holds a compiled-in definition for registryd — the only compiled-in
service definition there is:

| 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:

1. **The control socket** at `/run/services/peinit/control.sock`, which
   serves every runtime command for the lifetime of the system.
2. **The JFS device**: peinit opens `/dev/jfs` and adds the descriptor
   to its event loop, enabling ad-hoc job submission once Phase 2 runs.
3. **Loopback**: peinit brings up `lo` over netlink, because services
   that bind `127.0.0.1` need 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_

> Filesystem objects belonging to no single service — how entries are declared, what descriptors they get, and what failure does.

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_

> The registry-driven phase — reading the definitions, building and validating the graph, starting it, and deciding the boot succeeded.

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 `Requires` target** fails the dependent.
- **Warnings** — a `Readiness=Alive` service 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.

1. **eudev** — device management. SYSTEM, with `RequiredPrivileges`
   stripped to the minimum it needs. Starts before authd exists.
2. **lpsd** — the local identity database. Depends only on registryd.
3. **authd** — the identity authority. Depends on registryd and lpsd.
   Once it is ready, token minting is available.
4. **eventd** — logging and audit. Services started before it log into
   peinit's pre-eventd buffer.
5. **Networking** — the first service to receive a token from authd.
6. **Application services**, in dependency order.
7. **Login services**, last, so the system is operational before it
   accepts a session.

### 2.5.3.2 The bootstrap matrix

| 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.

> [!NOTE]
> The motivating case is a console login prompt being scribbled over by
> peinit's own progress messages. That is a scheduling preference, not a
> dependency — the prompt does not *require* anything to have started,
> it just wants the console to itself. Expressing it as a dependency
> would mean naming every service that might print, which changes every
> time the system does.

## 2.5.5 Boot success

A boot is successful once every Critical service has held a
dependent-satisfying state continuously for a grace period:

| 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](/peios/advanced-peios/peinit/boot/boot-modes.md).

---

# 2.6 Boot Modes

_Peios / Advanced Peios / peinit / Boot_

> Full, Safe and Recovery — the escalation path from normal operation to last-resort maintenance, and what causes each downgrade.

peinit boots in one of three modes, forming an escalation path from
normal operation to last-resort maintenance.

```
Full boot ---+-- success ----------------> counter reset, operational
             |
             +-- cycle w/ Critical ------> Safe mode (no reboot)
             |
             +-- conflict w/ Critical ---> Safe mode (no reboot)
             |
             +-- Critical failure -------> sync + reboot --+
                                                           |
Safe boot ---+-- success ----------------> counter reset,  |
             |   operational (reduced)                     |
             +-- Critical failure -------> sync + reboot --+
                                                           |
                             counter increments <----------+
                                        |
                             counter >= N ---------> Recovery mode
                             Phase 1 failure ------> Recovery mode
```

## 2.6.1 Full mode

The default. Every boot-triggered service starts in dependency order, as
§2.5 describes.

## 2.6.2 Safe mode

Safe mode starts a reduced set. Eligibility is a filter *within* the
boot-triggered set, not a replacement for it: a service with no `boot`
trigger does not auto-start in Safe mode whatever its `SafeMode` or
`ErrorControl` says, and remains demand-only. Within the boot-triggered
set, two categories are eligible:

- **Critical services** (`ErrorControl=Critical`) start. If one fails,
  the ordinary Critical failure path applies — restart budget, reboot,
  counter increment, eventually recovery.
- **`SafeMode=1` services** are attempted best-effort. If one fails,
  Safe mode continues without it.

`ErrorControl=Critical` implies `SafeMode`, so a Critical service does
not have to declare both.

### 2.6.2.1 What caused the downgrade

The rebuild discards the Full-mode graph, so 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.

> [!NOTE]
> Safe mode is purely a boot-sequencing concern. Once booted, an
> administrator starts anything by hand exactly as in Full mode. It is
> for a system whose configuration is broken but whose TCB is healthy.

### 2.6.2.2 Entry

- **A cycle involving a Critical service at boot.** Graph validation
  detects it and peinit downgrades in place, without rebooting — the
  cycle is a configuration error, and rebooting would find it again.
- **An unresolvable conflict involving a Critical service at boot.**
  Same reasoning.
- **`peios.safemode=1`** on the kernel command line.

Safe mode is not entered because a Critical service crashed at runtime.
That follows the ordinary path: restart budget, reboot, counter
increment, recovery.

## 2.6.3 Console output

`peios.quiet=N` bounds what peinit writes to the console:

| 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 on-disk counter that turns a repeated failure into an escalation — its cycle, and the threshold that reaches Recovery.

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.

> [!NOTE]
> The counter catches boots where peinit runs but the system never
> reaches health — a crash-looping Critical service being the usual
> case. It deliberately does not try to catch a peinit too broken to
> reach its own increment. Recovery mode is itself a peinit mode, so a
> peinit that cannot start cannot deliver recovery either, and a higher
> count could not help. That failure belongs to binary integrity, not to
> the counter.

---

# 2.8 Recovery Mode

_Peios / Advanced Peios / peinit / Boot_

> The last resort — no TCB guarantee, an administrator shell, offline registry access, and what remote recovery there is.

Recovery mode is the last resort. There is no TCB guarantee and no
degraded boot to speak of — it is a maintenance environment that hands
the administrator an unrestricted SYSTEM shell on the console.

## 2.8.1 Entry

- The boot attempt counter reaching the threshold (§2.7).
- `peios.recovery=1` on the kernel command line.
- Any of the Phase 1 failures listed in §2.3, most importantly a
  registryd that will not start or will not serve.
- A Phase 2 registry read that fails or times out, or invalid boot
  configuration.
- A required provisioned path that cannot be created or secured.

## 2.8.2 What peinit does

peinit records the reason as a KMES audit event, then:

1. Completes Phase 1 steps 1–5 if they have not been reached yet.
2. Ensures the base registry structure exists, so the shell sees a
   normal layout even on a system that has never been provisioned.
3. Attempts to start registryd. A failure here is ignored — recovery
   delivers a shell whatever registryd's state.
4. Skips all Phase 2 services.
5. Starts a shell on `/dev/console` from a compiled-in definition, with
   no registry dependency: `/bin/recsh` if it is present and
   executable, otherwise `/bin/sh`, running as SYSTEM with a fixed
   environment of `PATH=/sbin:/bin`, `TERM=linux` and `HOME=/`. peinit
   does not care where either binary comes from.
6. Logs the failure reason to the console.

If the shell exits, peinit respawns it. Recovery never exits to an
unmanaged PID 1.

If neither `/bin/recsh` nor `/bin/sh` can be exec'd, peinit cannot
deliver a shell at all. It logs the reason to the console, syncs, and
halts — PID 1 exiting would panic the kernel. A missing shell is a
binary-integrity failure and sits outside the boot-attempt machinery's
remit.

The shell receives `/dev/console` duplicated onto its standard streams,
but peinit does not call `setsid()` or acquire a controlling terminal
for it. The shell is not a session leader, so job control is not
available in the recovery shell.

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.

> [!NOTE]
> Recovery mode is not a degraded boot; it is a maintenance environment.
> There are no security protections beyond what the kernel provides. The
> administrator has a SYSTEM shell and the corresponding responsibility.

## 2.8.3 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.

> [!NOTE]
> `/bin/recsh` exists so a system can ship a purpose-built recovery
> shell — one that bundles the offline registry tools, presents
> guidance, or curates a command set — without making it mandatory.
> `/bin/sh` is the floor where it is absent. peinit treats both as
> opaque executables.

## 2.8.4 Remote recovery

Recovery requires console access: physical, IPMI or serial. Two
post-v1 features address headless servers — rolling the registry back to
a last-known-good state from the recovery shell, and an emergency sshd
started without registry involvement.

---

# 3.1 Services

_Peios / Advanced Peios / peinit / The Service Model_

> The primary unit of management — names, the two service types, and how a forking daemon differs from a simple one.

A service is the primary unit of management: a definition in the
registry, a runtime state, a Security Descriptor, and at most one
running main process. Definitions live under
`Machine\System\Services\<name>`, where the key name *is* the service
name. peinit reads them at Phase 2 boot and on an explicit
reload-config.

## 3.1.1 Names

A service name is 1 to 128 bytes drawn from `[A-Za-z0-9._-]`. Any other
byte makes the name invalid.

Two exclusions are deliberate. `/` is out because names map directly
onto cgroup identifiers (§5.1) and onto registry key names, and a name
containing a separator would mean something different in each. `:` is
reserved for peinit's own synthetic naming.

## 3.1.2 The two types

### 3.1.2.1 Simple

A long-running daemon. peinit forks, installs a token, and execs the
binary; the process *is* the service. When it exits, the service has
stopped. This is the default and covers nearly everything — registryd,
authd, sshd, application services.

Readiness comes from the `Readiness` field. `Notify`, the default,
waits for `READY=1`. `Alive` treats the process as ready the moment it
exists.

The service goes Active on readiness and stays Active until the process
exits or something stops it.

### 3.1.2.2 Oneshot

A run-to-completion task: database initialisation, a schema migration, a
directory that has to exist. peinit forks, installs a token, execs, and
waits for the exit.

`Readiness` is ignored. A Oneshot's readiness is always "it exited
successfully", because `READY=1` is meaningless from a process whose job
is to finish. Success means exit code 0, or any code listed in
`SuccessExitCodes`.

The differences from Simple are:

- A successful exit goes to Completed. With `RemainAfterExit=1` it stays
  there; without, it passes through Completed to release dependents and
  then goes Inactive.
- A non-zero exit goes to Failed.
- `ExecStartPost` runs after the successful exit rather than after a
  readiness signal, and does not run at all if the Oneshot failed.
- `StartTimeout` covers the entire execution, from the first pre-hook to
  the process exiting.

`RemainAfterExit` matters when the Completed state itself is the useful
information — so a status query shows a migration as finished rather
than as inactive.

## 3.1.3 Forking daemons

peinit does not support them. A service that double-forks to daemonise
itself is working around a problem that does not exist when the service
manager tracks the process it spawned, and peinit tracks its child
through a pidfd obtained at fork. There is no `MAINPID=`, and no way to
point supervision at a different process.

A legacy binary that insists on double-forking is wrapped by whoever
packages it — a script with a `--no-daemon` flag, typically. That is a
packaging concern.

---

# 3.2 The Definition Schema

_Peios / Advanced Peios / peinit / The Service Model_

> Every field a service definition can carry, with its registry type and default, and why decoding is all-or-nothing.

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_

> How a field's value must be written, as distinct from what it means — strings, identity, privileges, directories and environment.

Rules that apply to how a field's value is written, as distinct from
what it means.

## 3.3.1 Strings

A string field that is present is non-empty, unless this section says
otherwise for that field. Three fields treat the empty string as
absence:

- `Identity` — empty is the same as absent, and defaults to
  `LocalService`.
- `HookIdentity` — empty is the same as absent, and falls back to the
  service's `Identity`.
- `DisplayName` and `Description` — empty is the same as absent.

`WorkingDirectory`, when present, is a non-empty absolute path. Whether
it exists, is a directory, and is reachable is checked when the service
starts, not when the definition is read.

`TTYPath` is checked for emptiness before absoluteness: an empty value
means no terminal, and a non-empty relative path is rejected.

## 3.3.2 Identity and HookIdentity

Either a well-known principal name — `SYSTEM`, `LocalService`,
`NetworkService` — matched case-insensitively and canonicalised, or a
literal SID string such as `S-1-5-18`. Anything else is passed to authd
verbatim to resolve (§4.3).

## 3.3.3 RequiredPrivileges

Privilege names, matched **case-sensitively** against the published
privilege table. A name that does not match exactly fails token
materialisation and therefore the service start, so `SeTCBPrivilege`
does not start a service that `SeTcbPrivilege` would.

## 3.3.4 RuntimeDirectories

Each entry names one directory directly under `/run`. Entries are
non-empty relative names; an entry equal to `.` or `..` is rejected, as
is any entry containing `/`, `\`, a NUL, or a control character. A dot
inside a name is fine — `app.sock.d` is a valid entry.

For an entry `foo`, peinit creates `/run/foo` immediately before
launching the service's main process, with a descriptor granting full
access to SYSTEM, Administrators, and the service's own SID. Hook
processes inherit the service's environment and identity rules but do
not cause provisioning — the directories belong to the main start.

If creation or descriptor assignment fails, the start fails with
`ParentSetupFailure`.

peinit does not remove runtime directories when a service stops. `/run`
is a boot-scoped tmpfs and the next boot clears it.

## 3.3.5 Environment

Each entry is `KEY=VALUE`, with a non-empty key containing no NUL. An
entry that does not split into that shape is a decode error.

## 3.3.6 SuccessExitCodes

Each entry is a decimal integer from 0 to 255 — a process exit code.
Signal names and ranges are not accepted. Code 0 is always success and
does not need listing. Duplicates are collapsed.

## 3.3.7 Dependency and handler names

Entries in `Requires`, `Wants`, `BindsTo` and `Conflicts`, and the
value of `OnFailure`, are validated 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_

> What makes a service start by itself — the trigger kinds, how arity is enforced, and how the set is extended.

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_

> Two start-time checks in the same form over the same four check types, differing only in what a failure means.

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_

> The four fields holding executable commands, how all four are parsed, how the executable is located, and ExecReload signals.

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 rather than a live registry view — the boot and activation generations, field mutability, and pinning.

peinit operates on snapshots, not on a live view of the registry. Two
generation concepts govern when a registry change takes effect.

## 3.7.1 The in-memory model

peinit maintains a full in-memory model of every service definition. The
registry is read synchronously exactly twice: during Phase 2 boot,
before meaningful supervision has started, and during a reload-config,
which an administrator initiated and which is bounded. At all other
times peinit works from the model.

> [!NOTE]
> The model exists because the event loop cannot block on a userspace
> service during normal supervision. In single-threaded PID 1
> a blocking syscall blocks everything — child reaping, watchdog expiry,
> shutdown signals, every other event stops while the syscall is stuck.
> Reading the registry means waiting on registryd, and registryd is a
> service peinit supervises.

Change notifications arrive as events on a pollable descriptor. peinit
subscribes to `Machine\System\Services\` and `Machine\System\Init\` at
boot, using the LCS watch mechanism — a persistent subscription that
delivers change events on a key descriptor, with an OVERFLOW event when
the kernel-side queue is exceeded (Peios Kernel TRM §5).

Any drained watch event triggers a full reload of the configuration,
not a targeted re-read of the changed key. That covers the OVERFLOW case
by construction, and it is why an administrator writing one value causes
every definition to be re-read.

## 3.7.2 The boot generation

At the start of Phase 2, peinit reads all definitions, builds the
dependency graph and validates it. The plan and the graph are fixed at
that point, and the boot executes against them.

The watches are armed as the event loop starts, which is after the plan
is fixed but while boot-plan services are still starting. A registry
write during that window — from an install script, a post-hook, a
package transaction — triggers a reload like any other. 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_

> What happens when a definition disappears from the registry, depending on whether the service is running.

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;
- `stop` is accepted, so an administrator can drain it cleanly, using
  the cached `StopTimeout`;
- `start`, `restart` and `reload` are rejected with `UNKNOWN_SERVICE` —
  there is no definition to work from;
- `status` reports the current runtime state with `definition_removed`
  set, so the draining instance is visible rather than silent.

`definition-removed` is a flag on the existing runtime state, not a
state of its own. The state machine (§6.1) and the command × state
matrix (§10.3) are unchanged by it.

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 — where peinit obtains or creates it, and where it is installed.

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.

> [!NOTE]
> `HookIdentity` typically grants *more* than the service itself.
> peinit does not validate filesystem permissions on hook binaries, so
> where it grants elevated privileges the administrator is responsible
> for the hook binary and its parent directories not being writable by
> anything lower-privileged.

If materialisation fails at any point — authd unreachable, an identity
that cannot be resolved, a KACS error — no child exists yet, and the
start fails with `ParentSetupFailure` for the main process or
`PreHookFailure` for a hook.

---

# 4.2 The SYSTEM Path

_Peios / Advanced Peios / peinit / Service Identity_

> peinit mints SYSTEM tokens itself, which is what breaks the bootstrap circle for registryd, lpsd, authd and eventd.

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.

> [!NOTE]
> Minting is an interim mechanism. The intended model is for peinit to
> *derive* the token from its own handle, through a KACS
> duplicate-with-additions operation — kernel-attested as a descendant
> of peinit's real token, and requiring no token-minting privilege at
> all. That operation does not exist in KACS yet, so peinit mints.

---

# 4.3 The authd Path

_Peios / Advanced Peios / peinit / Service Identity_

> Every non-SYSTEM token comes from authd — peinit resolves no identities itself, and the interface belongs to authd.

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:

1. peinit sends the `Identity` value verbatim.
2. authd routes it to an identity source — a built-in for the
   well-known principals, lpsd for local accounts, a connector for
   domain accounts.
3. The source returns the principal's user SID and group SIDs.
4. 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.created` event, and it says `LocalService` for 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_

> The SID derived from a service's name and carried in its token's group list — why it exists, and the uppercasing rule.

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 `ﬁ` 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_

> peinit only ever removes privileges from a token and never adds one, on either identity path.

`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 answering different questions — the rights, the default, the check, and hot reload.

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:

1. Takes the caller's token, captured when the connection was accepted.
2. Resolves the target service and its ServiceSecurity descriptor. A
   command naming no definition and no addressable definition-removed
   entry returns `UNKNOWN_SERVICE`; peinit does not invent a descriptor
   to check against.
3. Calls AccessCheck with the caller's token, that descriptor, the
   generic mapping above, and the right the command needs.
4. On denial, returns `ACCESS_DENIED` and records the attempt as an
   `access.denied` event carrying the caller's SID, the target, the
   requested right by name, the requested access bits and the granted
   bits.
5. 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_

> Shutdown and configuration reload are checked against peinit's own descriptor rather than any service's.

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 them all at once.

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_

> Evaluating conditions and asserts before anything is forked — the two check mechanisms, and how results are cached.

Before anything is forked, peinit evaluates the service's conditions and
asserts (§3.5). The definition comes from the in-memory cache, so this
never touches the registry.

1. Conditions are evaluated. Any failure transitions the service to
   Skipped and abandons the start. Skipped satisfies dependents.
2. If every condition passed and the service has asserts, they are
   evaluated. Any failure transitions the service to Failed with cause
   `AssertionError` and abandons the start.

Only when 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 deciding to start a service and its binary running — timeouts, runtime directories, hooks and the fork.

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

1. Close the write end of the error pipe.
2. Register the read end with the event loop. peinit does not block on
   it — signals, control traffic, timers and log pipes stay serviceable
   while the child's setup is pending.
3. When the read end becomes readable or hangs up, read it
   non-blocking:
   - **Would block:** leave the source registered, the job stays in
     pending setup.
   - **EOF:** exec succeeded. Record the pidfd on the job, emit
     `job.started`, and only then apply readiness side effects such as
     Simple/Alive activation.
   - **Data:** setup failed. Parse the step and errno, log the specific
     failure, fail the service with `PreExecFailure`.

While setup is pending the job is not Running, and no dependent that
waits on this service's readiness is released.

## 5.3.8 Steps 8 and 9

The child's own path is §5.4. What follows exec is the readiness wait
and the post-readiness work:

- **Simple with `Readiness=Notify`:** wait for `READY=1`, then Active.
- **Simple with `Readiness=Alive`:** Active as soon as exec succeeds.
- **Oneshot:** wait for the exit. Success is Completed; without
  `RemainAfterExit` it then goes Inactive once dependents are released.
  A non-zero exit is Failed.

On readiness or successful exit, peinit runs `ExecStartPost` in sequence
into `hooks/`, releases the service's dependents, kills `hooks/`, and —
for a Simple service only — arms the watchdog if `WatchdogTimeout` is
non-zero and the health check timer if `HealthCheck` is set.

A post-hook that fails is logged and does not fail the service. The
state transition to Active or Completed happens before the post-hooks
run, so a service is already Active while its post-hooks are executing.

## 5.3.9 Failures before the fork

Steps 2, 4, 5 and 6 can all fail with no child in existence. peinit
handles all four entirely in the parent: clean up whatever cgroups were
created, fail the service with `ParentSetupFailure`, and return the
error to the caller. These are system-level resource problems — file
descriptor limits, PID limits, memory, cgroup filesystem errors — rather
than anything about the service.

---

# 5.4 The Child Path

_Peios / Advanced Peios / peinit / Starting a Service_

> The deliberately minimal straight line between clone3 returning and execve — streams, signals, oom_score_adj and the environment.

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_

> Every service environment is built from scratch in four layers, lowest precedence first, with nothing inherited.

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.

> [!NOTE]
> Write access to `Machine\System\Init\EnvVars\` is equivalent to
> compromising every service on the system. peinit does not filter
> variable names, consistent with the registry's write-authority threat
> model — the key's descriptor is the boundary. The recommended default
> is SYSTEM full control and Administrators read-only.

---

# 5.6 Health Checks

_Peios / Advanced Peios / peinit / Starting a Service_

> A watchdog says a service is still ticking; a health check says it still works — execution, overlap, failure and the flap constraint.

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.

> [!NOTE]
> Active health checks on `ErrorControl=Critical` services deserve
> caution. A false positive eventually exhausts the restart budget and
> reboots the machine. Critical services — registryd, authd, lpsd,
> eventd — are usually better served by a passive watchdog with a
> generous timeout: the service knows whether it is healthy and can stop
> sending keepalives when it decides it is not. peinit does not prevent
> health checks on Critical services and applies identical semantics to
> them, but the escalation path ends at a reboot.

---

# 5.7 Leaked Sub-Cgroups

_Peios / Advanced Peios / peinit / Starting a Service_

> A D-state process does not die when SIGKILLed — what happens to its cgroup, and how the leak becomes visible.

A process in uninterruptible kernel sleep — D-state, typically a hung
NFS mount or a failing disk controller — does not die when it is
SIGKILLed. Its cgroup cannot be removed while it is there.

peinit detects this through `cgroup.events`: after sending the kill it
arms a post-kill deadline, 5 seconds by default, and checks whether
`populated` is still 1 when the deadline fires.

## 5.7.1 What happens depends on which cgroup it is

For the **main process**, a survivor is fatal to supervision: the
service transitions to Abandoned with cause `ProcessUnkillable` and
peinit stops supervising it (§6.1).

For a **health check or a hook**, it is not. Those are diagnostic and
setup processes; they hold no service resources — no ports, no file
locks, no database connections — so a stuck one does not make the
service unmanageable. peinit orphans the sub-cgroup instead:

1. Marks it leaked, recording the path, the kind and the time.
2. Increments the service's cgroup generation, so the next start builds
   a fresh tree (§5.1).
3. Carries on supervising the service normally.

A leaked **pre-start check helper** is treated the same way: its
`checks/` sub-cgroup is recorded and the generation bumped, which matters
because otherwise the next start would build into a tree that still
contains the unkillable process.

The leaked cgroup stays in the hierarchy until the next reboot.

## 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.leaked` event** 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 `warnings` array, one entry per leak,
  each an object with the sub-cgroup path, its kind, and the time of
  detection:

  ```json
  {"path": "/sys/fs/cgroup/peinit/jellyfin/health", "type": "health", "detected_at": "2026-06-01T12:34:56.123456789Z"}
  ```

- A **start command** on a service with leaks returns a warning in its
  acknowledgement, saying the service has leaked sub-cgroups from a
  previous generation and that this indicates an I/O problem needing
  investigation.

The `type` is the same vocabulary everywhere: `health` for a leaked
`health/` sub-cgroup, `hooks` for a leaked `hooks/` one, `helper` for a
pre-start check helper's `checks/`, and `service_tree` for a leaked
service root — the last being the most serious, since it means the whole
tree including `main/` could not be reclaimed.

Whichever way it reaches you, what the leak means is the same: something
underneath the service is not responding to the kernel, and no amount of
restarting the service will fix it.

---

# 6.1 States

_Peios / Advanced Peios / peinit / The State Machine_

> The ten states a service can be in, the three that satisfy dependents, and the invariants that hold across all of them.

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

1. A service is in exactly one state at any moment. There is one state
   field and exactly one place in the implementation that assigns to it.
2. Only the transitions in §6.2 are performed. The assignment is gated
   by a central whitelist, so an unlisted transition is not merely
   avoided by convention — it cannot be written.
3. Only peinit transitions a service. The control socket produces
   operations; nothing outside peinit writes state.
4. A service object is securable independently of its process token. The
   ServiceSecurity descriptor governs who may manage the service; the
   process token governs what the service may reach. Neither implies
   anything about the other.
5. Readiness is per start generation. The generation increments on every
   transition into Starting, and a `READY=1` carrying a stale generation
   is rejected — so a notification from a previous incarnation can never
   be mistaken for this one's.

---

# 6.2 Transitions

_Peios / Advanced Peios / peinit / The State Machine_

> Every transition peinit performs — anything absent from the table is not performed — plus reset from Abandoned.

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 — the taxonomy, which causes are restart-eligible, OnFailure, the loop guard and the logging contract.

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`.

> [!NOTE]
> The startup failures are restart-eligible because a transient problem
> during startup should get another go. A pre-hook that failed because a
> network mount was briefly unavailable deserves a retry rather than an
> immediate give-up.

**Always-only.** `CleanExitRestart` applies when a Simple service exits
successfully and the policy is Always. It uses the same backoff and the
same budget as a failure, which is what stops a daemon that exits
cleanly in a tight loop from bypassing throttling entirely. It is never
treated as a `ProcessCrash`, and both status and events make clear the
process succeeded and was restarted only because the policy says so.

`CleanExit` is its non-restarting counterpart: straight to Inactive,
consulting neither policy nor budget.

**Budget-exempt.** `BindsToRecovery` takes a service from Failed to
Starting when its binding target returns. It is not subject to the
policy or the budget, because the service did not fail on its own — it
was stopped because its dependency went away.

**Never-restart.** peinit does not consult the policy at all:
`ExplicitStop`, `ExplicitReset`, `ShutdownWave`, `ConflictEviction`,
`BindsToPropagation`, `ProcessUnkillable`, `RestartBudgetExhausted`,
`ValidationError`, `CycleDetected`, `DependencyFailure`,
`AssertionError`, `ConditionSkipped`. 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_

> What happens when a restart-eligible cause occurs — the policies, backoff, the budget and when it resets, and exhaustion.

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_

> Telling a service to re-read its configuration without restarting — choosing the signal or command path, auto-detection, and interruptions.

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_

> The two notification fields a running service uses to adjust the deadlines it is held to, their caps, and where they do not apply.

Two notification fields let a running service adjust the deadlines it is
held to. Both are authenticated exactly as any other notification
(§10.5), and both carry microseconds.

## 6.6.1 The watchdog

`WatchdogTimeout` sets the interval peinit expects `WATCHDOG=1` pings
at. Zero, the default, disables it. Missing a ping is a
`WatchdogTimeout` cause and takes the ordinary restart path.

A service may change the interval at runtime by sending
`WATCHDOG_USEC=<value>`:

- A value greater than zero updates the interval **and re-arms
  immediately** — the current timer is cancelled and a fresh one starts
  from the moment the message was received, rather than the new interval
  applying only from the next ping.
- A value of zero disables the watchdog entirely, equivalent to
  `WatchdogTimeout=0`.

The runtime value does not persist. On a restart the interval reverts to
the definition's `WatchdogTimeout` converted to microseconds, and if
that is zero the watchdog starts disabled whatever the previous
incarnation had set.

`WATCHDOG_USEC` is honoured only while the service is Active. A service
that sends it while still Starting — before its own `READY=1` — is
ignored and gets the definition's value.

> [!NOTE]
> Runtime watchdog updates suit a service whose phases have genuinely
> different latency. A database engine might want a tight five-second
> watchdog in normal operation and sixty seconds during a compaction
> pass. The service knows its own phases better than whoever wrote its
> definition does.

## 6.6.2 Timeout extension

A service may ask for more time during a start, stop or reload by
sending `EXTEND_TIMEOUT_USEC=<value>`.

peinit sets the current phase's deadline to expire that many
microseconds from now. The extension **replaces** the deadline rather
than adding to it — each message sets an absolute deadline computed from
its own arrival — and may be sent repeatedly.

Because it replaces, a small value shortens the remaining time rather
than being ignored, and a value of zero sets the deadline to now.

### 6.6.2.1 The caps

The extended deadline cannot exceed four times the phase's base timeout:

| 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.

> [!NOTE]
> Timeout extension is for a service doing variable-duration work in a
> transition — replaying a write-ahead log on startup, say. It sends
> periodic messages to prove it is making progress; if it stops sending,
> the last deadline fires and peinit escalates as usual. The fourfold
> cap is what stops a buggy service extending forever.

---

# 7.1 Relationships

_Peios / Advanced Peios / peinit / Dependencies_

> The four relationship types — Requires, Wants, BindsTo and Conflicts — and what each means at start, at stop and on failure.

Four relationship types. Each says something different about how two
services interact at start, at stop, and on failure.

## 7.1.1 Requires

A hard dependency. If A `Requires` B:

- **Start.** B has to reach a dependent-satisfying state before A
  starts.
  If B is not running, peinit starts it with cause `DependencyStart`. If
  B enters Failed, A goes to Failed with cause `DependencyFailure`
  without attempting to start.
- **Stop.** Stopping B does not stop A. This is a start-ordering
  constraint, not a runtime coupling.
- **Runtime failure.** If B crashes while A is Active, A is unaffected
  and keeps running. B's own restart policy handles B.
- **Missing target.** A goes to Failed with `DependencyFailure`,
  detected at graph validation.

## 7.1.2 Wants

A soft dependency. If A `Wants` B, peinit starts B before A — with
cause `DependencyStart` — provided B exists and is not disabled. If B
fails to start, or does not exist at all, A starts anyway. There is no
stop or failure effect in either direction.

The waiting rule is where the difference from `Requires` actually lives:
a dependent blocked on a `Requires` target waits for it to reach a
*satisfying* state, while a dependent blocked on a `Wants` target waits
only for it to reach *any terminal* state, satisfying or not. That is
what makes `Wants` ordering rather than dependency — "start this first
if you can, but I will work without it".

## 7.1.3 BindsTo

A runtime coupling. If A `BindsTo` B:

- **Start.** Identical to `Requires`.
- **Stop.** If B stops for *any* reason — explicit stop, conflict,
  crash, shutdown — A stops too, transitioning to Stopping with cause
  `BindsToPropagation`.
- **Recovery.** When B returns to Active, peinit automatically restarts
  anything sitting in Failed with cause `BindsToPropagation` from B's
  stop. This is reactive rather than polled: peinit watches for the
  transition into Active from a non-satisfying state, and reacts to it.
  These restarts do **not** consume the restart budget — the dependent
  never failed on its own, it was stopped because its binding target
  went away.

`BindsTo` implies `Requires`. A definition may list both for clarity,
and if it does, the `BindsTo` semantics apply.

## 7.1.4 Conflicts

Mutual exclusion. Starting A while B is Active creates a stop operation
for B — source `ConflictResolution`, cause `ConflictEviction` — and A
does not start until B has left every state in which it could still be
running.

Conflicts are **symmetric**. If A declares `Conflicts = ["B"]`, starting
either one stops the other; B does not have to declare it reciprocally,
and peinit scans both a starting service's own conflicts and everything
that declares a conflict against it.

If both A and B are boot-triggered and they conflict, graph validation
detects an unresolvable conflict and fails both with `ValidationError`.

A missing conflict target is silently dropped — there is nothing to
conflict with.

> [!NOTE]
> `Conflicts` is for real mutual exclusion: two services binding the
> same port, or two implementations of one role where exactly one must
> run. It is not a way to solve resource contention that has a better
> answer.

## 7.1.5 Ordering and self-reference

Dependencies imply start ordering, and — except for `BindsTo` — imply
nothing about stopping. peinit starts dependencies before dependents;
during shutdown it reverses the same graph and stops dependents before
dependencies (§12.2), derived from the one graph rather than from a
separate stop-ordering configuration.

A service may not name itself in any dependency field. peinit rejects a
self-reference at graph validation as a `CycleDetected` failure, which
is what it is — a cycle of length one.

---

# 7.2 Graph Validation

_Peios / Advanced Peios / peinit / Dependencies_

> peinit validates the dependency graph before executing it — cycles, missing targets, and the errors and warnings it produces.

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=Alive` that something else depends on
  hard. `Alive` readiness means the process exists, which is no
  guarantee it is functional, so anything waiting on it is waiting on
  the wrong thing.

## 7.2.5 Multiple findings

A service can attract more than one finding in one pass — being both in
a cycle and missing a `Requires` target, say. The runtime state records
a single primary cause, chosen by precedence:

1. `CycleDetected`
2. `ValidationError`
3. `DependencyFailure`

The precedence affects only which cause is stored. Every other finding
for the service is retained beside it, in discovery order, and the
operation's failure message enumerates all of them:

```
CycleDetected: dependency cycle a -> b -> a (also: ValidationError:
health check interval exceeds the restart window) [2 findings]
```

The primary comes first and unqualified, so a service with one finding
reads exactly as it always did. A higher-precedence finding arriving
later demotes the previous primary rather than deleting it — breaking
the cycle should not be what it takes to discover the second fault.

Every finding is also emitted as its own `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_

> Starting everything whose dependencies are satisfied and repeating until nothing more can start — contexts, failure propagation and shutdown ordering.

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:

1. Everything that `Requires` or `BindsTo` it transitions to Failed with
   cause `DependencyFailure`.
2. Everything that `Wants` it is unaffected and starts normally.
3. Propagation is transitive: if A requires B and B requires C, and C
   fails, then B fails and then A fails.

## 7.3.3 On-demand start

Starting a service explicitly resolves its dependencies first:

1. Collect the transitive `Requires` and `BindsTo` closure.
2. Collect the transitive `Wants` closure, best-effort.
3. Validate the sub-graph — cycles, missing targets.
4. Resolve conflicts, stopping whatever has to stop.
5. Start the sub-graph with the same parallel algorithm. Dependencies
   started this way carry cause `DependencyStart` and operation source
   `DependencyPropagation`.

A service already in a satisfying state — Active, Completed or Skipped —
is not restarted. Its dependency is already met.

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 and how they differ beyond scope — including the three places a bad definition can surface.

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 with `ValidationError` and 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, and every fork peinit performs is one — its lifecycle, fields, retention and ownership.

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:

- `id` is assigned **before** the fork, so a job that never forks still
  has an identity.
- `pid` and `pidfd` land on the record only once exec success is
  confirmed by EOF on the error pipe. Until then they are held in
  pending setup state and the job is Created.
- A setup failure takes the job straight from Created to Failed.
  `ended_at_ns` records the **classification** time, `failure_cause`
  records what went wrong, and `pid`, `pidfd`, `started_at_ns`,
  `exit_code` and `exit_signal` all stay null. There was no process to
  have a PID or an exit status.
- `exit_code` and `exit_signal` are populated only when peinit observed
  an exit — never both, since a process either exits or is killed.
- For an Abandoned job, `ended_at_ns` records when peinit stopped
  supervising, and the exit fields stay null. Nothing exited.

`resolved_identity` is the identity *string* — `SYSTEM`,
`LocalService`, a SID — that was resolved for the execution.
`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_

> A requested state machine action as a first-class object — every control command creates one rather than mutating state directly.

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_

> Resolving a new operation against one already pending or running — merging, cross-type conflicts, and the principles behind both.

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

1. **Stop wins over start.** An explicit stop always takes priority. The
   administrator said stop, so stop; a queued start can follow.
2. **Later supersedes earlier.** Start then immediately stop means the
   stop wins and the start is cancelled, recorded as superseded.
3. **Merging is transparent.** The merged caller gets the original
   identifier and blocks on the original operation's outcome.

Reset is rejected outright while anything is in flight, because reset
means "clear a terminal state" and nothing in flight has one.

## 8.3.4 Dependency propagation

When a start executes against a service with unsatisfied dependencies,
peinit creates start operations for them:

- **`Requires`** — source `DependencyPropagation`. If one fails, the
  parent operation fails with `DependencyFailure`.
- **`Wants`** — source `DependencyPropagation`. If one fails, the parent
  continues.
- **`BindsTo` recovery** — source `BindsToRecovery`, created when a
  bound target returns to Active, for dependents sitting in Failed with
  cause `BindsToPropagation`.

Dependency-created operations follow the same resolution rules as
administrator-created ones. If a dependency is already starting because
something else also depends on it, the operations merge — and both
graph execution contexts are then associated with the one operation
(§7.3).

`BindsToRecovery` restarts are not subject to the restart budget. They
are created because a dependency returned, not because anything failed.

## 8.3.5 Restart policy and timers

A restart-eligible failure creates a start operation with source
`RestartPolicy` once the backoff delay elapses. It goes through the
ordinary validation and resolution: if an administrator has already sent
a stop, or the budget is exhausted, it is rejected.

A timer firing creates an operation based on the service's current
state:

| 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_

> The structured event peinit emits at every job and operation transition, plus its own audit and graph records.

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 arbitrary supervised process submitted by a service on behalf of its client — the delegation problem it solves, and its identity and lifecycle.

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

1. Forked with the JFS-provided token.
2. Runs in its own cgroup under `/sys/fs/cgroup/peinit/`, the id derived
   from the job's GUID rather than from a service name.
3. Output routed to eventd, tagged with the job's GUID.
4. On exit: emit `job.ended`, clean up the cgroup, drop the job.
5. 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_

> What the job and operation model looks like from outside — identifiers, waiting, and why merging is invisible to the caller.

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_

> The OnCalendar grammar peinit parses — component syntax, named shortcuts, timezones, daylight saving and precision.

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/step` matches the value and every multiple of the step above
  it, so `0/15` in the minute field is 0, 15, 30 and 45.
  `start..end/step` walks from start to end inclusive.

Weekdays are English names, case-insensitive, abbreviated or full, and
accept lists and ranges. `Mon` and `Monday` are the same day, as are
`Tue`, `Tues` and `Tuesday`.

## 9.1.2 Last day of the month

A `~` in place of the `-` between Month and Day counts the day from the
**end** of the month: `~01` is the last day, `~02` the second-to-last,
and so on. `*-*~01` is the last day of every month; `*-02~03` is the
third-to-last day of February.

Repetition combines with it, and steps in the day-of-month direction —
which means it walks the offset *downwards*, towards the end of the
month. `Mon *-05~07/1` covers the last seven days of May, and combined
with `Mon` resolves to exactly one day: the last Monday in May.

Wildcards, ranges and lists are also accepted after `~`, so `~*` matches
every day and `~03..07` the third- to seventh-to-last.

## 9.1.3 Named shortcuts

| 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/London` skips 2024 entirely.
- **Fall back**, where an hour repeats: a scheduled time inside the
  repeated interval fires exactly once, on the **first** occurrence.
  On re-arming, the same civil time resolves to the same instant, which
  is not later than the firing that just happened, so the timer advances
  to the next day rather than firing again.

## 9.1.6 Precision

Second-level. Unlike systemd, peinit does not accept fractional seconds
— service scheduling has no use for finer granularity, and dropping it
keeps the parser simpler. A fraction anywhere in the time component is a
parse error.

## 9.1.7 Examples

| 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. |

> [!NOTE]
> An expression that parses but can never match — `*-02-30`, or a fixed
> year already past — is not rejected at parse time. Computing its next
> occurrence walks forward a day at a time to the year 9999 before
> concluding there isn't one.

---

# 9.2 Evaluation and Arming

_Peios / Advanced Peios / peinit / Timers_

> A timer is a trigger rather than a service type — how one is armed, how it fires, and what multiple triggers do.

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_

> Where last-run history lives in the registry, how a missed firing is caught up after a reboot, and when the timestamp is written.

`TimerPersistent`, on by default, controls whether a run missed across a
reboot is caught up.

## 9.3.1 Where history lives

Last-run timestamps are `REG_QWORD` values in the registry, written
after a firing.

A service with a **single** timer trigger stores its timestamp as
`LastTimerRun` on the service's own key:

```
Machine\System\Services\<name>\LastTimerRun
```

A service with **multiple** triggers stores one per trigger under a
subkey, named by the schedule string:

```
Machine\System\Services\<name>\TimerState\<encoded-schedule>
```

A schedule contains characters — spaces, `:`, `*` — that are not valid
LCS value names, so the name is the schedule with every character
outside `[A-Za-z0-9._-]` percent-encoded, with uppercase hex digits.
This is the same encoding used for cgroup ids (§5.1). The schedule
`*-*-* 02:00:00` is stored as:

```
%2A-%2A-%2A%2002%3A00%3A00
```

Two identical schedule strings on one service encode to the same name
and therefore share one timestamp.

Timer firings are infrequent, so the write cost is negligible.

## 9.3.2 Catching up

On boot, for each persistent trigger:

1. Read the last-run timestamp.
2. Compute the next scheduled firing after it.
3. If that time has already passed, at least one run was missed: fire
   once, immediately.
4. Compute the next future occurrence normally.

Catch-up is always a **single** run however many were missed. A daily
timer that missed five days fires once on the next boot, not five times.

A trigger with no history at all is treated the same way, so its first
boot produces one catch-up firing.

`TimerPersistent=0` ignores history entirely — peinit does not even read
the registry for that trigger, and computes the next occurrence from
now.

## 9.3.3 When the timestamp is written

The timestamp is written after the timer fires and the start is
*initiated*, not after the service finishes. A service that crashes
mid-run is not re-triggered on the next boot: the run was attempted, not
missed.

A configuration reload re-arms every timer from the current time with no
catch-up, whatever `TimerPersistent` says. History is consulted at boot
only.

> [!NOTE]
> Timestamps for a multi-trigger service are keyed by schedule string,
> so changing a schedule orphans its history and the new schedule
> produces one spurious catch-up run. A stable trigger identifier would
> fix it; schedule changes are rare and one extra run is the whole cost.
> A single-trigger service is unaffected, since its timestamp lives at a
> fixed name.

---

# 9.4 Jitter and Clocks

_Peios / Advanced Peios / peinit / Timers_

> The random delay applied to each firing, which clock a timer is evaluated against, and what a clock change does.

## 9.4.1 Jitter

`TimerJitter`, zero by default, adds a random delay to each firing.
peinit draws a uniformly random whole number of seconds from zero to
`TimerJitter` inclusive, from the kernel's random source, and adds it to
the computed occurrence.

The delay is recomputed on every firing, so a daily timer with
`TimerJitter=900` fires at a different moment between 00:00 and 00:15
each day. With `TimerJitter=0` no randomness is consulted at all.

Jitter is applied **after** the calendar expression is evaluated and is
only ever added, so a timer never fires early — only late.

The boot catch-up firing is not jittered. It fires immediately, and
jitter applies from the next armed occurrence onward.

## 9.4.2 Which clock

The split is the point of this section, and it is not cosmetic.

**Calendar timers are wall-clock schedules**, so they are armed as
absolute `CLOCK_REALTIME` timers: `timerfd_settime` with
`TFD_TIMER_ABSTIME | TFD_TIMER_CANCEL_ON_SET`. `CANCEL_ON_SET` makes the
descriptor's read return `ECANCELED` whenever the realtime clock is
discontinuously changed — an NTP step, a manual set. peinit recomputes
the next occurrence against the new wall clock and re-arms, which is
what keeps `*-*-* 02:00:00` anchored to 02:00 across clock corrections.

**Interval timers are genuine relative durations** and use
`CLOCK_MONOTONIC`: the watchdog, health check intervals and timeouts,
restart backoff, and the Start, Stop and Reload phase timeouts. "Wait
thirty seconds" means thirty elapsed seconds regardless of what happens
to the wall clock. They are not armed with `CANCEL_ON_SET`, correctly —
a monotonic timer has no reason to be cancelled by a realtime set.

These are not separate descriptors. Every interval deadline is
aggregated onto one monotonic timerfd armed to the earliest of them.

**Last-run timestamps are recorded on `CLOCK_REALTIME`**, since they
record when a timer actually fired in wall-clock terms. The same firing
passes a monotonic timestamp into the operation machinery, because
operation timing is elapsed time.

## 9.4.3 Clock events

- **A realtime step at runtime.** The armed timer is cancelled; peinit
  recomputes against the new wall clock and re-arms. A backward step
  pushes the next firing later; a forward step that crosses an
  occurrence fires it once. If the step lands inside a jitter window,
  the firing happens at the un-jittered scheduled time — later than the
  schedule, never earlier.
- **Suspend and resume.** An absolute deadline that elapsed while
  suspended fires once on resume. The expiration count is ignored, so a
  long suspend produces one firing, not one per occurrence.
- **A missed occurrence within one uptime.** Fire once, then compute the
  next future occurrence. peinit never replays every occurrence that
  elapsed during a gap — the same rule as the cross-reboot catch-up.
- **A backward jump across a boot.** If the last-run timestamp is in the
  future relative to the current wall clock at boot, peinit treats the
  history as unknown and fires the catch-up immediately. This check is
  boot-time only; there is no runtime equivalent.
- **A wrong clock at boot.** A system that boots with a badly wrong
  clock and has NTP correct it later may fire a persistent catch-up
  spuriously or not at all. The runtime half is covered by
  `CANCEL_ON_SET` — once NTP corrects the clock, armed calendar timers
  are cancelled and recomputed — but the boot-time catch-up decision has
  already been made by then. Short of NTP-aware rescheduling, this
  remains an edge.

> [!NOTE]
> The calendar parser deserves heavy testing. Time parsing is a rich
> source of edge cases: month boundaries, leap years,
> last-day-of-month arithmetic, DST transitions and timezone database
> updates are all fertile ground for subtle errors, and every one of
> them is a bug that only appears on a particular day of a particular
> year.

---

# 10.1 The Control Socket

_Peios / Advanced Peios / peinit / The Control Interface_

> The Unix stream socket every runtime command arrives on — how it is created and protected, and the peer token it captures.

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_

> The fixed sequence a parsed command runs before it does anything — the shutdown gate, the rights checked, and what is filtered rather than denied.

A parsed command runs a fixed sequence before it does anything.

1. **The shutdown gate.** If peinit is shutting down, everything except
   `status`, `list` and `operation-status` is 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.
2. **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.
3. **AccessCheck.** The caller's token, the target's descriptor, the
   generic mapping, and the right the command requires (§4.6, §4.7).
4. **On denial**, return `ACCESS_DENIED` and record 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; a denial an administrator cannot see is
   indistinguishable from a bug.
5. **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_

> The defined answer for every command sent to a service in an unexpected state, including backoff, abandoned and definition-removed services.

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.

- `start` creates or merges into a **deferred** start operation and
  honours the remaining delay. It does not short-circuit the backoff.
  If the automatic restart later becomes due, it merges into the
  administrator's operation, so the identifier the caller holds is the
  one that executes.
- `stop` cancels both the pending restart and any deferred start, and
  the service goes Inactive. A subsequent automatic restart is refused,
  because the service is no longer in Backoff.
- `restart` cancels the automatic restart and queues an
  administrator-initiated one.
- `reload` and `reset` are invalid: there is no process to reload, and
  no terminal state to clear.

## 10.3.2 The Skipped column

`start` and `restart` clear Skipped before they run. A Skipped service
is not in a state a start can proceed from — the state machine permits
`Skipped -> Inactive` and nothing else — so the activation performs that
transition first, then re-evaluates the conditions from scratch. Both
outcomes are possible: the precondition that was missing at boot may now
hold, in which case the service starts; or it may still not, in which
case the service is skipped again, for whatever reason applies now.

The clear is reported like any other transition, so a console watching
the service sees it leave Skipped rather than appearing to jump.

`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_

> A full atomic re-read of the registry rather than a live update — what changes, and how removals and the compiled-in service are handled.

`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 `start` immediately.
- 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_

> The datagram socket services report on themselves over — authenticating a sender, applying a datagram, and the bounds on it.

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:

1. **Find the sender.** Scan for the current *service-main* job whose
   PID equals the sender's. Only a main job is ever a candidate, which
   is what makes `NotifyAccess=Main` the only mode there is — a hook or
   a health check cannot notify on a service's behalf.
2. **The job is Running.** A job still in pending setup has not exec'd.
3. **The job has a pidfd.**
4. **The pidfd still refers to that PID.** This is the step that
   matters: PID matching alone is racy, because a PID can be recycled
   between the sender writing and peinit reading. The pidfd was obtained
   atomically at fork, so verifying the PID against it is what makes the
   match sound rather than probable.
5. **The generation matches.** A job whose activation generation is
   not the service's current one is a previous incarnation, and its
   notifications are rejected. This is invariant 5 of §6.1 in force: a
   `READY=1` from the process that just crashed cannot mark its
   replacement ready.

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_

> Keeping file descriptors across a service's own restart — storing, removing, injecting and clearing them.

The fd store lets a service keep file descriptors across its own
restart. It pushes them to peinit, peinit holds them, and the new
process gets them back. That is what lets a stateful daemon — a web
server holding a listening socket, say — restart without dropping
connections it has already accepted.

`FdStoreMax` in the definition sets the maximum number of descriptors
peinit will hold. It defaults to **0**, which disables the store: most
services do not need it, and holding descriptors on behalf of a service
that will never ask for them back is pure cost.

## 10.6.1 Storing

When an authenticated datagram carries `FDSTORE=1` with descriptors
attached:

1. If `FdStoreMax` is 0, peinit logs the rejection and **closes** the
   descriptor.
2. If the store already holds `FdStoreMax` entries, peinit logs the
   rejection and closes the descriptor. The existing store is not
   modified — a full store does not evict.
3. `FDNAME=<name>` names it; an absent or empty name means `stored`.
4. `FDPOLL=0` marks the descriptor exempt from poll monitoring.

Either rejection emits 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):

1. They are placed consecutively from `SD_LISTEN_FDS_START` — descriptor
   3 — upward, with close-on-exec cleared: the only sanctioned
   exception to the close-on-exec discipline.
2. `LISTEN_FDS` is set to the count.
3. `LISTEN_FDNAMES` is set to a **colon-separated** list of names in the
   same order as the descriptor numbers.
4. The store is cleared. peinit no longer holds them.

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.

> [!NOTE]
> The `LISTEN_FDS` and `LISTEN_FDNAMES` convention is systemd's, so
> software already written to receive descriptors that way works
> unmodified. peinit's own C client library exposes the notification
> helpers but no descriptor-passing helper, so reaching the store from
> that library means constructing the control message by hand.

---

# 11.1 Wiring

_Peios / Advanced Peios / peinit / Output Handling_

> peinit is not a logging system but it holds the pipes at birth — where a service's output goes, how it is tagged, and terminal-attached services.

peinit is not a logging system — eventd stores, indexes and queries
logs. But peinit holds the pipes at birth. It decides where a service's
output goes, and it has to cover the window before eventd exists.

## 11.1.1 The pipes

Before exec, peinit creates a pipe for stdout and one for stderr with
`pipe2(O_CLOEXEC)`. The child's streams are redirected onto the write
ends; peinit keeps the read ends and watches them with epoll.

The blocking discipline is asymmetric, deliberately:

- The parent's **read** ends are non-blocking. PID 1 cannot afford to
  block on a read.
- The child's **write** ends keep ordinary blocking semantics.

That second half is what makes backpressure work. When a service
produces faster than peinit consumes, the kernel pipe buffer fills and
the service's own `write()` blocks, so the service slows down. Making
the write ends non-blocking would turn that into `EAGAIN` failures in
the service, converting a flow-control mechanism into an error the
service has to handle.

The child's stdin is redirected to `/dev/null`. peinit provides no
interactive input channel; a service needing input obtains it explicitly
— through a socket, or a stored descriptor — never through inherited
stdin.

The epoll instance itself is close-on-exec and is never inherited.

## 11.1.2 Tagging

peinit reads output line by line and tags each line with:

- the origin,
- the stream — stdout or stderr,
- a `CLOCK_REALTIME` timestamp,
- the job's identifier.

The origin is the service name for a main process. For a hook it is the
service name, the hook kind and its index — `jellyfin/ExecStartPre[0]`.
Reload commands are `<service>/ExecReload` and health checks
`<service>/HealthCheck`.

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_

> There is nowhere to send output until eventd binds its socket — what peinit buffers in memory, and why audit records are not logs.

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.

> [!NOTE]
> In practice the only services that run before eventd are registryd,
> lpsd, authd and eudev. The first three are Peios-owned with controlled
> output. eudev is the real overrun risk, being verbose about device
> enumeration. A crash loop's output is bounded by the restart budget.

## 11.2.1 Audit records are not logs

peinit's own audit records — access denials, critical failures, recovery
mode entry, graph errors, security-relevant transitions — are **events**
rather than logs. They go into the KMES kernel ring buffer, which
persists from the moment PKM loads: before eventd, before the registry,
before Phase 2.

So there is no pre-eventd buffer for them, and no window in which they
could be lost. eventd picks them up from the ring buffer when it
attaches, wherever in the boot they were emitted.

That distinction is the reason the two paths exist at all. Logs are
best-effort and lossy by design; audit events are neither.

---

# 11.3 Flood Protection

_Peios / Advanced Peios / peinit / Output Handling_

> The three bounds that stop a noisy service starving the event loop, and where loss is allowed and where it is not.

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_

> Switching from buffering to forwarding when eventd reaches Active — the record format, lossy delivery, and what happens when eventd goes away.

When eventd reaches Active, peinit switches from buffering to
forwarding.

1. Start sending to eventd's log datagram socket, at the path from
   `Machine\System\eventd\LogSocketPath`.
2. Replay the pre-eventd buffer, oldest first, preserving each line's
   timestamp and metadata. The replay is best-effort: these are
   datagrams on a loss-tolerant socket, so some may be dropped, and
   peinit does not block waiting to deliver them.
3. Switch to real-time forwarding — new output sent as it arrives.
4. Clear the buffer.

From there peinit is a relay: read from the pipes, tag each line,
forward. Audit events continue to flow as KMES events, entirely
separately.

## 11.4.1 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 the console and never a service's output — plus severity and the quiet setting.

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/console` and `/dev/ttyS<n>` can be the same device; where the
  device cannot be determined peinit assumes the terminal is held.
- At `2`, ordinary progress is dropped everywhere while errors still
  get through.

Suppressed messages are discarded rather than buffered for later.

Shutdown progress carries ordinary status severity, so `peios.quiet=2`
suppresses it along with every other kind of progress.

The autorun step in Phase 1 (§2.3) bypasses the policy entirely, on the
grounds that a script running that early and going wrong is worth
interrupting anything for.

---

# 12.1 Triggers

_Peios / Advanced Peios / peinit / Shutdown_

> The four paths that initiate a shutdown — the control socket, signals, the power button and a Critical service failure.

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_

> The ordered steps of a graceful shutdown, from entering the shutdown state to the final power action.

## 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 `ShutdownWave` while post-kill verification is
  pending. A cgroup still populated after the post-kill timeout takes
  the service to Abandoned with cause `ProcessUnkillable` and the cgroup
  is leaked. A Starting service whose job never forked skips the check
  and goes straight to Failed.
- **Inactive, Failed, Skipped, Backoff and Abandoned** do not
  participate. Abandoned cgroups stay leaked and shutdown continues.

## 12.2.4 Step 4: Stop in reverse dependency order

peinit builds the reverse dependency graph over hard dependencies and
stops in waves:

1. Each eligible service receives SIGTERM. Already-stopping ones do not.
2. Each has `StopTimeout` to exit.
3. On expiry, SIGKILL to the service's entire cgroup.
4. No service is stopped until everything depending on it has stopped.

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`,
  `ConflictEviction` or `BindsToPropagation`.

peinit requires the evidence to be present, to belong to a service
actually in Stopping, to carry a cause matching the service's current
cause, to name one of those four causes, and not to describe a deadline
earlier than its own start. Evidence that is missing, stale or
ambiguous **fails closed** rather than being guessed at — 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

1. Snapshot the mount table first, from `/proc/self/mountinfo`.
2. Attempt to unmount every remaining non-root mount in the namespace —
   not only what peinit mounted, so the Phase 1 set is covered by
   construction.
3. Process in descending path depth, so children go before parents.
4. A mount point already gone is a successful no-op.
5. On failure, attempt a read-only remount. If that fails too, record it
   and continue.
6. The root is never unmounted, but is remounted read-only at the end. A
   failure there is recorded and does not stop step 8.

## 12.2.8 Step 8: Sync and the final action

This step is irreversible. Cleanup failures retained from steps 6 and 7
affect diagnostics only and never block it.

1. `sync()`. Called on all three paths — graceful, forced and
   Critical reboot.
2. `reboot(2)` with `RB_POWER_OFF`, `RB_AUTOBOOT` or `RB_HALT_SYSTEM`.
3. If `reboot(2)` returns, the final action failed. peinit enters a
   minimal failed-shutdown state, keeps PID 1 alive, records the
   failure, and retries the same action no more than once a second. It
   does not restart services and does not enter recovery mode —
   finalisation has begun and there is nothing to go back to.

## 12.2.9 Shutdown during boot

A shutdown requested while Phase 2 is still running takes effect
immediately, through the same classification: Starting services are
SIGKILLed, services that reached Active are stopped gracefully, and the
boot is abandoned.

---

# 12.3 Signals

_Peios / Advanced Peios / peinit / Shutdown_

> Every signal is blocked and read through a signalfd from the event loop — the setup, the signals handled, and reaping.

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_

> The three shutdown paths that reach the kernel and how much work each does on the way, including what survives a failure.

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_

> The two boundaries peinit sits at — the kernel handing it a SYSTEM token, and peinit handing identity to services.

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, and the much narrower set it actually exercises.

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.

> [!NOTE]
> Minting is interim. The intended model derives a service's token from
> peinit's own handle through a KACS duplicate-with-additions operation
> — kernel-attested as a descendant of peinit's real token, and needing
> no minting privilege. That would let peinit drop
> `SeCreateTokenPrivilege` entirely, which is the point: the privilege
> to mint arbitrary tokens is the strongest thing peinit holds and the
> one it has the least use for.

---

# 13.3 The Attack Surface

_Peios / Advanced Peios / peinit / Security_

> What the socket descriptors mean in practice, and what the autorun directory exposes.

| 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_

> The properties peinit does not violate, and the code paths that make each of them structural.

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 the same way again — what losing it does in each phase and at runtime.

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_

> What breaks when authd, eventd or JFS is unavailable, and what a bound dependency that never becomes satisfying does.

## 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 sleep ignores SIGKILL — what Abandoned means, and the generational escape that lets peinit move on.

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_

> Descriptors, processes, memory and disk running out — how each reaches single-threaded PID 1, and what the OOM killer does.

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_

> The three things peinit writes and how each is made to survive a power loss — plus damaged files, timers, and an interrupted shutdown.

## 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 — service definitions, boot configuration, operational parameters and the watches it holds.

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_

> Every value compiled into peinit that no registry key changes — paths, timeouts, limits, environment and the descriptors it applies.

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 is the local registry source — it holds hives in SQLite and serves them to the kernel over RSI. Where it sits, and what this manual covers.

**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](/peios/advanced-peios/peios-kernel/lcs/overview.md),
and the RSI wire protocol itself is
[specified in PSPK](/peios/advanced-peios/pspk/registry-source-interface/scope.md). 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 vocabulary this manual borrows from the kernel-side documentation, and the terms loregd adds.

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 — the hive declarations it takes, and how they are validated.

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 a single request is accepted, and any failure in it is fatal.

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 report `wal`, the open fails. WAL mode is what allows
  concurrent readers alongside a writer (§4.1), so silently running
  without it is not acceptable.
- `PRAGMA foreign_keys=ON`. Neither schema declares a foreign key, so
  this constrains nothing.
- `PRAGMA busy_timeout=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_

> What ends the loregd process, what pointedly does not, and what happens to the data in each case.

## 2.3.1 What ends the process

loregd exits when any of the following happens:

- **The kernel closes the registry device.** Reading from
  `/dev/pkm_registry` returns end-of-file, the request loop returns, and
  loregd shuts down cleanly with status 0. This is the normal path when
  the registry subsystem goes away.
- **A termination signal arrives.** `SIGTERM` and `SIGINT` are trapped.
  The handler closes the device, which unblocks the read loop and
  produces the same clean shutdown as above. This is how the service
  manager stops loregd.
- **A request cannot be framed.** If a message read from the device
  cannot be parsed as an RSI request, loregd treats it as unrecoverable
  and exits non-zero.
- **Startup fails.** Any error in §2.2 is fatal.

On shutdown, in-flight requests are drained before the process exits
(§4.2), 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.

> [!NOTE]
> This is worth knowing when diagnosing a system whose registry has
> started failing: the symptom is failing operations, not a dead daemon,
> and loregd will still be running and still registered.

## 2.3.3 What happens to the data

Persistent data is durable at the point each transaction commits;
SQLite finalises any outstanding WAL state as the connections close.

Volatile data 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_

> The single-row table every hive database carries, the current version, and how a version mismatch is handled.

Every hive database carries a schema version in a single-row table:

```sql
CREATE TABLE schema_version (
    version INTEGER NOT NULL
);
```

The current version is **1** — the only version that has existed.

loregd checks the version as it opens each database (§2.2, step 4) and
takes one of three paths:

| 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_

> The four data tables every hive database has — keys, path entries, values and blanket tombstones — and their columns.

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

```sql
CREATE TABLE keys (
    guid           BLOB NOT NULL PRIMARY KEY,
    name           TEXT NOT NULL,
    name_folded    TEXT NOT NULL,
    parent_guid    BLOB,
    sd             BLOB NOT NULL,
    volatile       INTEGER NOT NULL DEFAULT 0,
    symlink        INTEGER NOT NULL DEFAULT 0,
    last_write_time INTEGER NOT NULL
);
```

| 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

```sql
CREATE TABLE path_entries (
    parent_guid       BLOB NOT NULL,
    child_name        TEXT NOT NULL,
    child_name_folded TEXT NOT NULL,
    layer             TEXT NOT NULL,
    target_type       INTEGER NOT NULL,
    target_guid       BLOB,
    sequence          INTEGER NOT NULL,
    PRIMARY KEY (parent_guid, child_name_folded, layer)
);

CREATE INDEX idx_path_entries_target
    ON path_entries (target_guid)
    WHERE target_type = 0;
```

A path entry is one layer's opinion about one child name under one
parent. Several layers may hold entries for the same name; resolving
between them is the kernel's job, not loregd's.

| 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

```sql
CREATE TABLE [values] (
    key_guid       BLOB NOT NULL,
    name           TEXT NOT NULL,
    name_folded    TEXT NOT NULL,
    layer          TEXT NOT NULL,
    type           INTEGER NOT NULL,
    data           BLOB,
    sequence       INTEGER NOT NULL,
    PRIMARY KEY (key_guid, name_folded, layer)
);
```

| 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

```sql
CREATE TABLE blanket_tombstones (
    key_guid       BLOB NOT NULL,
    layer          TEXT NOT NULL,
    sequence       INTEGER NOT NULL,
    PRIMARY KEY (key_guid, layer)
);
```

A blanket tombstone hides *every* value a key holds in the layers beneath
it, rather than naming one value the way a `REG_TOMBSTONE` entry does.
One row per key per layer.

---

# 3.3 The Volatile Store

_Peios / Advanced Peios / loregd / Storage_

> Volatile keys never reach the hive file, so each hive attaches a second in-memory database mirroring the persistent schema.

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:

```sql
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_

> Names are case-insensitive but case-preserving, implemented by storing both the written form and a folded one.

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:

```sql
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**.

> [!IMPORTANT]
> Lowercasing is not case folding, and the three corrections above do not
> close the gap. 219 codepoints fold to something other than their
> Unicode 16.0 simple case folding, and for 47 of those, case-insensitive
> matching fails outright: two spellings that name the same key do not
> reach the same folded form.
>
> Greek final sigma is the most reachable example. `ς` folds to itself,
> so a key stored as `Σ` is not found by a lookup for `ς`. The Cyrillic
> historic letters at U+1C80–U+1C88 and the Greek symbol variants
> `ϐ ϑ ϕ ϖ ϰ ϱ ϵ` behave the same way. Characters introduced in Unicode
> 16.0 — the Garay block among them — have no case mappings in the 15.0
> tables at all, so their case pairs never match.
>
> The Cherokee blocks fold in the opposite direction to the standard,
> across 172 codepoints. Matching inside a hive stays self-consistent,
> but the bytes written to the folded columns are not what another
> implementation folding the same names would produce.

---

# 4.1 Connections

_Peios / Advanced Peios / loregd / Concurrency_

> Each hive holds a small fixed set of SQLite connections — what each carries, and which one an operation runs on.

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=wal` on 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 one descriptor — identifying the target hive, and the operations that resolve differently.

RSI requests arrive multiplexed on the `/dev/pkm_registry` file
descriptor. Each carries a request id and a transaction id in its header.

loregd reads messages into a single 16 MiB buffer, copies each one out,
and hands it to a **new goroutine** — one per request, with no cap on how
many may be in flight. Responses are serialised by a mutex, because one
write to the device must correspond to exactly one response. On shutdown
the in-flight goroutines are drained before the process exits.

## 4.2.1 Identifying the target hive

Most operations name a key GUID (or, for lookups and enumerations, a
parent GUID), and loregd must decide which hive owns it.

A `guidCache` maps GUID to hive. It is seeded at startup with every
hive's root GUID, and maintained as keys come and go:

- `RSI_CREATE_KEY` stores the new GUID **immediately**, before the
  transaction that created it commits, and registers an abort hook to
  evict it if that transaction rolls back. The immediate store is
  necessary because the cache-miss probe reads through the pool, which
  cannot see uncommitted rows.
- `RSI_DROP_KEY` evicts immediately outside a transaction, or through a
  commit hook inside one.
- `RSI_DELETE_LAYER` evicts the GUIDs its deletion orphaned.

On a miss, loregd probes each hive in turn:

```sql
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 ids are allocated by the kernel and bound to connections lazily — beginning, read-write against read-only, committing and aborting.

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_

> The busy timeout is set deliberately shorter than the kernel's request timeout — waiting for the write connection, and abandoned transactions.

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.

> [!IMPORTANT]
> A transaction that has written any `volatile.*` table holds a write-table
> lock on it for the transaction's whole lifetime. A volatile read on any
> other connection waits for that lock, and the wait is not bounded by
> `busy_timeout`.
>
> This reaches **every** read operation, not only ones a caller thinks of
> as volatile. All four read operations query `main` and `volatile` in one
> `UNION ALL` statement (§5.1), and hive resolution probes
> `volatile.keys` as well (§4.2). So while a transaction that has touched
> volatile data is open, ordinary reads against the same hive block in
> their serving goroutine rather than returning a status.
>
> The relationship is symmetric. A read-only snapshot that has read a
> volatile table holds a read-table lock for as long as the snapshot
> lives, and volatile writes wait for it. Read-only snapshots are what
> serve the kernel's `REG_IOC_BACKUP`, which is expected to be
> long-lived.

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 in the main schema and volatile data in the attached one — how an operation picks, and the ones that span both.

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:

```sql
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 behind enumerations carry no ORDER BY, so their order is not stable across calls — and what that means for the kernel.

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_VALUES` response. It
  comes from an unordered `UNION ALL` like everything else, but is
  emitted unsorted.
- The **orphan-GUID array** in an `RSI_DELETE_LAYER` response. 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_

> The four key operations and the SQL behind each — create, read, write and drop.

## 5.3.1 RSI_CREATE_KEY

The request's volatile flag selects the target table (§5.1). For a
persistent key:

```sql
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

```sql
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:

```sql
-- 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:

```sql
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_

> Lookup, create, hide, delete and enumerate — the operations over the per-layer entries that give a key its names.

## 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.

```sql
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

```sql
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:

```sql
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:

```sql
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:

```sql
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_

> Querying, setting and deleting value entries, conditional writes, and setting a blanket tombstone.

## 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:

```sql
-- 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:

```sql
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`.

```sql
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:

```sql
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:

```sql
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`.

```sql
-- 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_

> Delete-layer and flush — the two operations that ignore the request's transaction id and take the write connection directly.

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:

```sql
-- 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.

```sql
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:

```sql
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_

> The seven RSI status codes loregd returns, and the three the interface defines that it never produces.

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_

> loregd is specified against SQLite rather than an abstract store — how it relates to SQLite, the Windows hive format, and RSI.

## 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](/peios/advanced-peios/pspk/registry-source-interface/scope.md) 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 single persistent sink for everything a Peios system records about itself — the shape of the daemon, and what it is not.

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_

> A TRM proposal for software that has not been written — what in it is a contract, and how versions and constants are treated.

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 this manual borrows unchanged from elsewhere in the corpus, and the ones it introduces.

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_

> Where eventd sits against Windows Event Log, ETW, journald, Prometheus and OpenTelemetry, and what it deliberately leaves to others.

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_

> The four stages from shared memory to a committed row, why the ring buffers are the only buffer, and how sharding scales writes.

eventd is the primary consumer of the KMES ring buffers. Events travel
from shared memory to a committed database row in four stages.

1. **Drain.** One thread per CPU reads events from that CPU's ring
   buffer, following the lock-free read protocol PSPK specifies (§2.2).
2. **Detect.** The drain thread compares each event's sequence number
   against the last it saw for that CPU. A jump means events were lost,
   and the loss becomes a gap record (§2.5).
3. **Hand off.** The drain thread copies the event out of the mapped
   region and passes it to the writer thread that owns the shard it
   routes to (§2.3).
4. **Write.** The writer thread accumulates events into a transaction
   and commits, sizing the batch to whatever throughput allows (§2.4).

Two principles govern the whole pipeline, and most of its behaviour
follows from them rather than from anything specific to a stage.

## 2.1.1 The ring buffers are the only buffer

eventd holds no large intermediate queue between KMES and SQLite. The
handoff channel 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_

> Discovering CPUs and attaching to their rings, the drain threads, copying, generation changes and sequence tracking.

## 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_pos` starts at `tail_pos` on first attachment, so eventd begins
  at the oldest surviving event rather than at the newest.
- The drain loop loads `write_pos` with acquire ordering, checks
  `tail_pos` for lapping, validates the event's structural integrity,
  and advances `read_pos` by `event_size`.
- After reading an event it re-reads `tail_pos` — the torn-read check —
  to detect that KMES overwrote the event while it was being copied.
- With nothing to read it uses the notification protocol, setting
  `need_wake` and waiting on the futex, rather than spinning.

## 2.2.3 Copying

A drain thread 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:

1. Records the sequence number of the last event it processed.
2. Calls `kmes_attach` again for its CPU, obtaining a descriptor for the
   resized buffer.
3. Maps the new buffer.
4. Unmaps the old one and closes the old descriptor.
5. Scans the new buffer for the first event whose sequence number
   exceeds the recorded one.
6. 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:

```sql
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_

> Event writes distributed across up to 256 independent databases — the count, the assignment, the writer threads and the handoff.

## 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):

```text
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_

> How each writer thread commits — explicit transactions, adaptive batch sizing, WAL checkpointing and prepared statements.

## 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.

1. When the first event is available, the writer opens a transaction and
   records the start time.
2. It reads available events from its drain threads and inserts them.
3. After each group of inserts it commits if any of these holds:
   - no assigned drain thread currently has an event available
   - the batch holds `MaxBatchSize` events
   - `MaxBatchLatencyMs` has elapsed since the first event entered it
4. Otherwise it keeps reading and inserting.
5. With nothing available and an empty batch, it sleeps until a producer
   wakes it.

The first condition is what makes the algorithm adaptive. Under light
load the input drains immediately, so a batch of three events commits at
once and the exposure window is microseconds. Under sustained load
batches grow until they hit the size cap or the latency cap, whichever
comes first, and the per-commit fsync is amortised across thousands of
rows.

The writer chooses its own insert-group size, subject to a group never
letting a batch exceed the size cap or stay open past the latency cap.

Both bounds are configuration (§A). The defaults are 10000 events and
100 milliseconds — the tightest latency of the three stores, for the
same reason the durability setting is the strictest.

## 2.4.3 WAL checkpointing

WAL mode accumulates log data until a checkpoint copies it back into the
main database file. Under sustained writes the log grows.

Each writer triggers a checkpoint when its write-ahead log reaches
`WalCheckpointPages` (§A), in `SQLITE_CHECKPOINT_PASSIVE` mode —
checkpointing as much as it can without blocking readers. If a passive
checkpoint cannot make progress because readers hold pages, the writer
does not block: it keeps writing and retries after a later commit.

Checkpointing runs on the writer thread and briefly serialises with
insert work, which is inherent to SQLite rather than a choice — a
database cannot be checkpointed and written concurrently. Passive mode
is the lightest option available, yielding immediately when readers hold
pages, and the per-checkpoint cost is bounded by the threshold.

## 2.4.4 Prepared statements

Each writer prepares its `INSERT` once at startup and reuses it for
every row, which keeps SQL parsing and planning off the hot path
entirely.

---

# 2.5 Gap Detection

_Peios / Advanced Peios / eventd / Event Ingestion_

> Per-CPU sequence numbers are the whole mechanism — what causes a gap, what a gap record holds, and how lapping is detected.

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_

> Records eventd generates about itself, written straight to a shard and never through KMES — when, where and in what order.

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_

> One table per shard with every KMES header field as its own column, the untouched payload blob, and the write-time indexes.

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_timestamp` on `events(timestamp)`

Time-range filtering is the foundational access pattern — nearly every
query carries a `SINCE` — and it is the one index eventd never sheds,
whatever the write pressure (§3.4). Every other index is the adaptive
system's business.

## 3.1.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_

> The MessagePack schema of each of the five synthetic event types, whose field names are stable query-language surface.

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_

> The event store directory — naming, creation, opening an active shard, quarantine and historical shards.

## 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.

> [!NOTE]
> Nothing in eventd's design constrains the protection on that
> directory. The three sockets get Security Descriptors (PSPU §3.3), but
> the store paths are ordinary configuration, and the metadata database
> inside this directory holds the descriptor governing administrative
> operations (§3.5). A process that can write the directory can rewrite
> that descriptor.

## 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:

1. WAL mode, `PRAGMA journal_mode=WAL`
2. `PRAGMA synchronous=FULL`
3. the `events` and `metadata` tables (§3.1)
4. the `idx_events_timestamp` index
5. the `schema_version` and `created_at` entries

## 3.3.4 Opening an active shard

1. Open in WAL mode.
2. Set synchronous to FULL.
3. Read and verify `schema_version`. Missing or unrecognised is a
   **startup failure**. No migration is attempted.
4. Verify structural integrity — the required tables and write-time
   indexes exist. Failing this, with SQLite reporting no corruption, is
   a **startup failure**.
5. If SQLite reports corruption while opening or verifying, quarantine
   and replace (below).

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_

> Which secondary indexes are worth their write cost depends on what a deployment queries — how eventd decides, converges and sheds.

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:

```text
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_

> The one database in the store that is not a shard — its tables, its concurrency, and why recovering it is cheap.

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.

> [!NOTE]
> `admin_sd` is the only access control state eventd keeps outside the
> registry. Everything on the read path lives under
> `Machine\System\eventd\Security\` where the registry's own access
> control protects it; this one sits in a file in the event store
> directory, whose protection is not otherwise specified (§3.3).

## 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

1. Open `eventd-meta.db`, creating it if absent.
2. Verify the schema version and required `meta` entries; recreate from
   defaults on failure.
3. Load `index_counters` and `rollup_counters` into memory.
4. Load `desired_indexes` and `desired_rollups` into memory.
5. Load `sequence_checkpoints`, for diagnostics only.
6. Discover the material indexes in each shard from its schema and
   compare against the desired set.

eventd resumes convergence from wherever each shard happens to be. It
neither drops nor rebuilds indexes at startup: a shard's material set is
a fact to be observed, not a state to be restored.

---

# 3.6 Retention

_Peios / Advanced Peios / eventd / Event Storage_

> Bounding disk growth on two axes, age and size, with both enforced — how the pass runs and how space is reclaimed.

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:

```text
logical_live_bytes = (page_count - freelist_count) * page_size
```

from `PRAGMA page_count`, `PRAGMA freelist_count` and
`PRAGMA page_size`, taken after attempting a passive WAL checkpoint. The
event store's total is the sum across every shard.

Pages freed by retention do not count, because they are reusable by
future inserts. Counting them would make retention chase its own tail:
each deletion would free pages that still counted against the limit,
prompting more deletion.

When `EventRetentionMaxBytes` is non-zero and the total exceeds it:

1. Identify every non-current boot ID present in the shards.
2. Order them by their newest event timestamp, oldest boot first.
3. Delete each of those boots entirely, across all shards, one boot at a
   time, until the total is within the limit or no non-current boots
   remain.
4. If still over, delete the oldest events of the **current** boot by
   timestamp, across all shards, until within the limit.

Size pressure prefers boot boundaries. Deleting a whole old boot removes
a self-contained unit — its sequence numbers, its gap records and its
startup event go together — and it preserves recent events across the
boundary, which is what an operator investigating a reboot needs. Only
when whole boots are exhausted does eventd start on the current one.

## 3.6.3 Running it

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 stored record carries a boot_id — what it is for, where it is stored, how uniqueness is assured, and how a boundary is detected.

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 the log socket and writes the store, independent of the event path — batching, durability and what it adds to a record.

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.

> [!NOTE]
> Linux's default `SO_RCVBUF` for a Unix datagram socket is around
> 212 KB, roughly a thousand typical log records. A batch commit takes
> one to ten milliseconds, and that buffer is the only thing absorbing
> arrivals in the window. A service dumping a stack trace will lose
> datagrams, which is the intended degradation for a loss-tolerant path
> (PSPU §3.4) — the alternatives being backpressure or unbounded
> buffering, and the design forbids both.

Splitting into a reader and a writer with a bounded handoff — the shape
the event path uses (§2.3) — would decouple them. It is not done, and
the reasoning is that log loss is tolerable by design (PSPU §3.4), log
volume is normally well below event volume, and the single-thread model
avoids a handoff channel and its backpressure semantics entirely.

Where log throughput does become the constraint, sharding the log store
the way the event store is sharded is the larger lever; splitting the
thread only moves the stall.

## 4.1.2 Batching

The writer batches on the same adaptive principle as the event writer
(§2.4), with the socket receive queue as its input. A transaction opens
when the first valid record is available and commits when any of these
holds:

- no further datagram is immediately available in the receive queue
- the batch holds `LogMaxBatchSize` records
- `LogMaxBatchLatencyMs` has elapsed since the first record entered it

If a datagram yields more valid records than fit in the remaining space,
the writer commits, then continues with the same datagram in a new
transaction. A transaction never exceeds the size cap and never stays
open past the latency cap — a batched datagram cannot smuggle a larger
transaction past either.

The defaults (§A) are 5000 records and 500 milliseconds. The latency is
five times the event writer's, because log loss on power failure is
acceptable where event loss is not, and larger, less frequent
transactions are more efficient at the moderate volumes logs normally
run at.

## 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_

> A single database rather than a directory of shards, its schema, its write-time indexes, and why there is no adaptive indexing here.

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_timestamp` on `logs(timestamp)` — time-range filtering, as
  everywhere.
- `idx_logs_origin` on `logs(origin)` — "show me logs from X", which is
  the dominant log query.
- `idx_logs_job_id` on `logs(job_id) WHERE job_id IS NOT NULL` — a
  partial index for "show me logs for job X". Partial because
  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_

> The log store is a file rather than a directory — its path, creation, opening, concurrency and checkpointing.

## 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:

1. WAL mode, `PRAGMA journal_mode=WAL`
2. `PRAGMA synchronous=NORMAL`
3. the `logs` and `metadata` tables (§4.2)
4. the `idx_logs_timestamp`, `idx_logs_origin` and `idx_logs_job_id`
   indexes
5. the `schema_version` and `created_at` entries

## 4.3.3 Opening

1. Open in WAL mode.
2. Set synchronous to NORMAL.
3. Verify the schema version. Missing or unrecognised is a **startup
   failure**; no migration is attempted.
4. Verify structural integrity — required tables and indexes present.
   Failing this, with SQLite reporting no corruption, is a **startup
   failure**.
5. On SQLite reporting corruption, quarantine and replace.

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, on the same thread, with a shorter default and its own reclamation.

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 the metric socket and writes samples — processing a record, out-of-order samples, batching and durability.

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:

1. **Resolve the series** from name and labels — and, for a histogram,
   bucket boundaries — through the in-memory series cache (§5.3). A
   series that does not exist is inserted into `series` with the
   record's type, and the cache is updated.
2. **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.
3. **Insert the sample** into `samples` with the resolved `series_id`,
   the timestamp and the value. SQLite assigns `samples.id`, which is
   the deterministic tiebreaker among samples sharing a timestamp
   (§5.2). A histogram's data is encoded as the canonical MessagePack
   sample map and stored in `histogram_data`.

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 `MetricMaxBatchSize` samples
- `MetricMaxBatchLatencyMs` has elapsed since the first sample entered
  it

A datagram yielding more samples than fit is split across transactions,
the writer committing before continuing with the same datagram. Neither
cap is ever exceeded.

The defaults (§A) are 5000 samples and 1000 milliseconds — the longest
latency of the three writers. Metrics are typically sampled every
fifteen seconds, so a one-second commit window accumulates a whole
sweep's worth without any latency that a dashboard could notice. Under a
burst, where a collection agent submits every core, disk and interface
at once, the size cap is what forces a timely commit.

## 5.1.4 Durability

WAL mode with `synchronous=NORMAL`, as the log store (§4.1). Metric loss
on power failure is acceptable, so per-transaction fsync buys nothing.

---

# 5.2 Series and Samples

_Peios / Advanced Peios / eventd / Metric Storage_

> The metric store is organised around series rather than records — the series table, the canonical label string and the boundaries blob.

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:

1. `boundary_count`, `u32` little-endian
2. that many IEEE-754 `f64` values, each little-endian, in the validated
   order the producer sent

It exists only to identify histogram series and to resolve hash
collisions, and is never returned in a query result.

### 5.2.1.3 Hashes narrow, they do not decide

`label_hash` and `boundaries_hash` are 64-bit FNV-1a over the exact
bytes of the canonical string or blob, with offset basis
`0xcbf29ce484222325` and prime `0x100000001b3`. The high bit is cleared
before storage, `hash & 0x7fff_ffff_ffff_ffff`, so the value always fits
SQLite's signed `INTEGER`.

A lookup always verifies the full `labels` string, and for a histogram
the full `boundaries` blob, after narrowing by hash. A hash is an index
key, never an identity: two label sets that collide are still two
series.

### 5.2.1.4 Uniqueness

The table carries `UNIQUE(name, labels, boundaries_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_timestamp` on `samples(series_id, timestamp, id)`
  — the dominant pattern is "samples for series X over range Y in
  deterministic order", and this one composite index serves the series
  lookup, the range filter and the `(timestamp, id)` ordering in a
  single scan.
- `idx_series_name` on `series(name)` — name lookups.
- `idx_series_label_hash` on `series(label_hash)` — series resolution on
  the ingestion path.
- `idx_rollups_series_function_window` on
  `rollups(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_

> Turning each arriving sample into a series_id once, on the single ingestion thread — the cache, and how to size it.

Every arriving sample must be turned into a `series_id` before it can be
inserted. This happens once per sample on the single metric ingestion
thread, so it is the hottest lookup in the daemon and the reason a cache
exists at all.

## 5.3.1 Resolving

1. Compute the canonical label string: sort by key in unsigned UTF-8
   byte order, encode each pair `key=value`, join with commas. The empty
   label set encodes as the empty string. No escaping — ingestion has
   already rejected the delimiters (§5.2).
2. Hash it.
3. For a histogram, compute the canonical boundary blob and its hash
   from the **validated, producer-supplied order**. eventd never sorts
   boundaries. For counters and gauges both are null.
4. Look up `series` by `name`, `label_hash`, and for histograms
   `boundaries_hash`.
5. On a match, verify the full `labels` string, and for histograms the
   full boundary blob. If the record's type differs from the existing
   series' type, drop the record (§5.1). Otherwise use the existing
   `series_id`.
6. On no match, insert a new `series` row and use the new identifier.

A histogram whose boundaries changed takes step 6: it is a new series
(PSPU §3.13). The old one keeps its historical samples and the new one
starts accumulating.

## 5.3.2 The cache

Resolution runs through a bounded in-memory cache mapping
`(name, canonical labels, boundaries hash, boundaries blob)` to
`series_id`. For counters and gauges the boundary components are absent.

A hit is a hash table lookup with no SQLite involvement. A miss costs
one `SELECT` on `name` and `label_hash`, after which the result is
inserted, evicting the least recently used entry if the cache is full.

The bound is `MetricSeriesCacheSize` (§A), default 50000, with LRU
eviction. It bounds **memory**, not the number of series: the `series`
table is uncapped and a new series is always created in the database.
A system with a million series and a 50000-entry cache uses memory
proportional to the cache, and at roughly 200 to 300 bytes an entry the
default costs 10 to 15 MB.

The cache starts empty after a restart and is warmed on demand — within
one collection cycle, typically fifteen seconds, every active series is
cached. There is no pre-warming pass, because reading a million-row
`series` table at startup to populate a 50000-entry cache would be work
spent to discard most of its result.

## 5.3.3 Sizing it

The cache is sized for the set of actively reporting series, and
behaves badly below it.

Below that, LRU does not help, because every series is equally hot: each
collection cycle evicts the overflow and reloads it, producing a fixed
number of `SELECT`s 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_

> The metric store file — its path, creation, opening, concurrency and checkpointing.

## 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

1. WAL mode.
2. `PRAGMA synchronous=NORMAL` — the log store's reasoning, for the same
   reason: metric loss on power failure is acceptable (§4.1).
3. The `series`, `samples`, `rollups` and `metadata` tables (§5.2,
   §5.6).
4. Every write-time index.
5. The `schema_version` and `created_at` entries.

## 5.4.3 Opening

1. Open in WAL mode with synchronous NORMAL.
2. Verify the schema version. Missing or unrecognised is a **startup
   failure**; no migration.
3. Verify structural integrity — required tables and indexes present,
   including `rollups` and its lookup index. Failing this, with SQLite
   reporting no corruption, is a **startup failure**.
4. On SQLite reporting corruption, quarantine and replace, exactly as
   for a shard (§3.3): matching `-wal` and `-shm` files renamed with the
   same `.corrupt.<timestamp_ns>` suffix, `.N` appended if the name is
   taken, and a fresh empty store created at the configured path.

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_

> The metric retention pass, its long default, why downsampling is not yet part of it, and how space is reclaimed.

Metric retention runs on the same background thread as the other two,
after the log store (§3.6, §4.4). Both limits are enforced and the more
aggressive wins.

## 5.5.1 The pass

1. Delete rows from `samples` older than `MetricRetentionDays` (§A)
   until none remain.
2. If `MetricRetentionMaxBytes` is non-zero and the store's logical live
   size exceeds it, delete the oldest samples by timestamp until it is
   within the limit.
3. Track every `series_id` whose samples were deleted by either step.
4. Delete every `rollups` row for those series.
5. Delete every `series` row 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_

> Precomputed aggregates so a wide query does not scan millions of raw samples — the rollups table, the registry, and serving a query from one.

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_OVER` and `SUM_OVER` record 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
  `SINCE` clause record the same function using
  `AdaptiveRollupScalarWindowSeconds` (§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_OVER` across series combines per-series
  AVG rollups **weighted by `sample_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_

> The four phases between a query string and an answer, what can only fail after planning, and when payloads are decoded.

A query arrives as one string (PSPU §3.15). Turning it into an answer
has four phases before any data is read: parse, plan, authorize,
execute.

## 6.1.1 Parsing

The string is parsed into a syntax tree. The parser:

1. Identifies the mode from the first token — `EVENTS`, `LOGS` or
   `METRIC`.
2. Extracts the primary selector: a type pattern, a `FROM` list, or a
   metric name with an optional label selector.
3. Collects every clause, in whatever order they appear.
4. Validates that the clauses suit the mode — `CONTAINING` only in log
   mode, `RATE` only in metric mode, `SELECT` only where a result schema
   is not fixed.

Parse errors are returned immediately, before anything is opened, read
or authorized. A malformed query costs a decode and a parse.

## 6.1.2 What can only fail later

Some failures need data. The parser cannot know whether a metric name
resolves to a counter or a histogram, how many series a selector
matches, or whether the effective range exceeds the cross-type lookback
limit — those depend on the store, so they surface at planning or
execution time (PSPU §3.B).

The practical consequence is that the same query string can parse
everywhere and fail on one machine: a metric selector matching one
series on a two-core box matches two on a four-core one.

## 6.1.3 Planning

Planning resolves what the query will actually touch:

- which concrete identifiers the data could carry — event types, log
  origins, metric names — because access control resolves per identifier
  and a broad selector authorizes nothing by itself (§7.4)
- which series a metric selector matches, and whether they are
  type-homogeneous
- which fields the query references, for both authorization and
  frequency accounting (§6.5)
- which stores are involved, including any cross-type source

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_

> Making every result order total and deterministic so paging works — the tiebreakers, and why insertion order is never the answer.

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_

> What the query language translates to directly, what does not translate, where access control sits, and how aggregation is handled.

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 run against every database in the store, active and historical — how results merge, and the unbounded case.

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_

> What every query records for the adaptive indexer, and the concurrency and timeout limits it runs under.

## 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).

> [!NOTE]
> Per-caller limits would need a way to identify the connecting process
> beyond its token — a process GUID from the connection. That is the same
> class of missing primitive as the datagram peer identity that leaves
> `origin` unverifiable (PSPU §3.28), and the global limit is what stands
> in for it.

## 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_

> How a live watch is implemented — commit generations, latency, the DISTINCT seen set, cross-type re-evaluation and backpressure.

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, through KACS descriptors, and decides nothing itself — caller identity, rights and timing.

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).

> [!NOTE]
> `EVENTD_CLEAR` is defined and nothing yet uses it: no operation deletes
> records on a caller's behalf, and retention deletes on nobody's behalf
> (§3.6). It is reserved for administrative deletion. The consequence
> worth knowing is that a descriptor written today with `GENERIC_WRITE`
> already grants it, and will start granting a real capability the moment
> one exists.

## 7.1.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 carrying a descriptor — matching, resolution, storage, and the first-boot defaults.

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:

1. Look for an exact match on the full identifier, `kacs.access_denied`.
2. Remove the last dot-separated component and look again, `kacs`.
3. Repeat.
4. Fall back to the wildcard, `*`.

The first match wins; a more specific pattern overrides a less specific
one.

## 7.2.3 Storage

Descriptors are registry values under the eventd security subtree:

```text
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.

> [!NOTE]
> This is the one piece of access control state outside the registry, and
> the protection of the directory holding it is not otherwise specified
> (§3.3). A process able to write the event store directory can rewrite
> the descriptor governing eventd's own policy.

---

# 7.3 Per-Field Control

_Peios / Advanced Peios / eventd / Access Control_

> Granting some fields of a record and not others through object ACEs — how field GUIDs are derived rather than registered.

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.

```text
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):

```text
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_

> Access control is the third phase of query evaluation — which clauses count as referencing a field, and why denial is silent.

The order in which eventd evaluates a query is fixed (PSPU §3.18), and
access control is the third phase — before predicates, transforms,
grouping, aggregation, ordering and pagination. What follows is the
sequence within that phase.

1. **Obtain the caller's token** from the connection (§7.1). Failure
   denies the query.
2. **Parse** the query to establish its data sources and filters (§6.1).
3. **Discover the concrete identifiers** the query could touch — event
   type strings, log origin strings, metric name strings. A broad
   selector authorizes nothing by itself: `EVENTS`, `EVENTS kacs.*`,
   `LOGS` without `FROM` and `METRIC cpu.*` are each resolved identifier
   by identifier.
4. **Resolve and check** each discovered identifier: find its descriptor
   by hierarchical matching (§7.2), 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).
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.
6. **Apply field verdicts to predicates.** Where the query references a
   field in a predicate or shaping clause and a matching identifier does
   not grant it, that identifier's records contribute nothing — exactly
   as if its root had been denied. The query is **not** rejected.
7. **Cross-type sources** get the same treatment. A denied root, or a
   denied field needed to evaluate the condition, makes the condition
   evaluate as though no matching cross-source data existed.
8. **Execute**, with root filtering already part of the logical row set.
9. **Re-resolve per result identifier.** For each distinct concrete
   identifier in the resulting rows, resolve its descriptor, build the
   object type list with field GUIDs, call `kacs_access_check_list` with
   the token, the descriptor, `EVENTD_READ`, the list and an audit
   context naming the identifier, and cache the per-field results.
10. **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 `WHERE` predicates
- metric label filters in a primary selector
- `GROUP`, `COUNT BY`, `TOP N BY`, `SORT` and `DISTINCT` fields
- event and log aggregation arguments — `SUM`, `AVG`, `MIN`, `MAX`
- metric transforms and terminal aggregations, all of which read the
  fixed `value` field: `RATE`, `DELTA`, `P50`, `P95`, `P99`, `AVG`,
  `MIN`, `MAX`, `SUM`, `AVG_OVER`, `MIN_OVER`, `MAX_OVER`, `SUM_OVER`
- a metric boot filter, which reads `boot_id`; and an explicit metric
  type predicate, grouping, sort or distinct, which reads `type`

`SELECT` is not on this list. It shapes output and is applied last, so a
field it omits was still available to every earlier phase — and
conversely, selecting a field the caller may not read removes the field,
not the record.

## 7.4.2 Denial is silent, not fatal

Step 6 excludes rather than rejects, and this is the decision most worth
being explicit about, because rejecting is the more informative
behaviour and that is exactly the objection to it.

A rejection would tell the caller that some identifier exists, matches
its query, and carries a field it may not read — three facts about data
it was not permitted to see, delivered by the mechanism meant to
withhold them, and enumerable by trying queries and watching which ones
fail. Excluding costs the caller a result narrower than it appears;
rejecting costs the model the property it rests on.

Cross-source fields already worked this way (step 7); primary-source
fields now match them.

## 7.4.3 Field authorization does not depend on presence

A field's authorization is resolved from the name **as written**,
against each concrete identifier, whether or not any record of that
identifier actually carries it.

Payload fields vary between records of the same type, so a rule turning
on presence would require the scan that authorization is meant to
precede.

## 7.4.4 Internal values are not fields

Row identifiers, series identifiers, ordering tiebreakers and series
type checks are not query-language source fields unless the mode exposes
them as fixed fields or the query names them (§6.2). Errors raised by
internal checks never carry a denied field's value.

A metric result's `value` **is** a source field, because it is either a
raw sample or a scalar derived from raw samples.

## 7.4.5 Filtering is part of the logical result

Access filtering is not a presentation step. Aggregating, ordering or
paginating over unreadable records would leak them through counts,
through ordering, and through the gaps in pagination.

eventd may push authorization predicates into SQL when the identifier
set is known, or read candidates and filter them before aggregating
(§6.3). The externally visible result is identical to filtering first,
and `COUNT`, `COUNT BY`, `TOP N BY` and every other aggregate reflect
only what the caller may see.

## 7.4.6 The audit trail

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, and a large result cannot afford one per record — the record-level, field-level and descriptor caches.

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.

> [!NOTE]
> Re-obtaining the peer token periodically would narrow the window, and
> would also mean a streaming query could change what it returns halfway
> through for reasons the client cannot see. The snapshot is the simpler
> contract and is what PSPU §3.14 states; the exposure is the cost.

---

# 7.6 The Write Path

_Peios / Advanced Peios / eventd / Access Control_

> There is no per-record access control on the way in — what stands in its place, and what that leaves open for an operator.

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.

> [!NOTE]
> Closing this needs a primitive eventd does not have: a way to obtain
> the peer's token for a **datagram**, as `kacs_open_peer_token` does for
> a stream connection. With one, an ingestion socket could carry a
> descriptor governing which origins a sender may write, checked per
> datagram — which is the shape the read path already has. Until it
> exists, confining which processes can reach the socket at all is the
> only available control, and it is coarse: it is a decision about
> processes, where the thing worth controlling is names.

## 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_

> The four subsystems eventd needs before it can do anything, the one that is not really a dependency, and where it sits in the boot.

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_

> The seven startup phases, which either complete or fail entirely — configuration, KMES attachment, storage, sockets and the rest.

Startup proceeds in seven phases, and either completes or fails
entirely.

## 8.2.1 Phase 1 — Configuration

1. Read every configuration key under `Machine\System\eventd\`. The six
   required keys are `EventStorePath`, `LogStorePath`, `MetricStorePath`,
   `QuerySocketPath`, `LogSocketPath` and `MetricSocketPath`; a missing
   or invalid one fails startup.
2. Read the optional keys and apply compiled-in defaults for those
   absent (§A).
3. Arm a persistent watch on the subtree, for runtime changes (§8.3).

## 8.2.2 Phase 2 — KMES attachment and shard sizing

4. Discover the CPU count by calling `kmes_attach` with incrementing
   CPU identifiers from 0 until `EINVAL`. The call requires
   SeSecurityPrivilege in the effective token. Discovering no CPUs fails startup.
5. Map each per-CPU ring buffer.
6. Resolve the active shard count from `StorageShards` — the CPU count
   when it is 0, the configured value otherwise.
7. Compute the shard-to-CPU assignment (§2.3).

## 8.2.3 Phase 3 — Storage

8. 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.
9. Open or create the log store — schema verified, WAL,
   `synchronous=NORMAL`, quarantine on corruption (§4.3).
10. Open or create the metric store, likewise (§5.4). The series cache
    starts empty and fills on demand.
11. 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

12. Read the current boot ID from peinit.
13. Search every readable event shard for committed rows carrying it.
14. **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.
15. **Rows exist** — a restart within the boot. Derive each CPU's resume
    point from the maximum committed `sequence` for
    `(boot_id, cpu_id)` across every readable shard; CPUs with no rows
    resume at 0 (§3.7).

## 8.2.5 Phase 5 — Sockets

16. Create the query socket at `QuerySocketPath`. A stale pathname left
    by a crash is unlinked first if it is an `AF_UNIX` socket; if the
    path exists and is not a socket, startup fails.
17. Create the log socket at `LogSocketPath`, same rule.
18. Create the metric socket at `MetricSocketPath`, same rule.
19. 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

20. One drain thread per CPU, each beginning to read its ring buffer.
21. One writer thread per active shard.
22. The log ingestion thread.
23. The metric ingestion thread.
24. The retention thread.
25. The adaptive indexing and rollup policy thread.

## 8.2.7 Phase 7 — Ready

26. Write and **commit** a `synthetic.startup` event recording the boot
    ID, the shard count and the per-CPU resume points (§3.2). The commit
    happens before readiness is signalled.
27. 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_

> Which settings apply immediately, which wait for a restart, which do neither, and what SIGHUP does.

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_

> Persisting as much in-flight data as possible without blocking indefinitely — the sequence, and the timeout that bounds it.

When peinit signals a stop, eventd persists as much in-flight data as it
can without blocking indefinitely.

## 8.4.1 The sequence

1. **Stop accepting.** Unlink all three socket paths so no new client
   can reach them, and stop accepting query connections. Existing
   streaming queries are terminated with an error. The log and metric
   socket descriptors stay **open**.
2. **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.
3. **Final event drain.** Each drain thread performs one last drain
   cycle from its ring buffer.
4. **Final commit.** Every writer commits its current batch immediately,
   whatever its size. The log and metric writers do the same.
5. **Record sequence state.** Derive the per-CPU last committed
   sequence from committed rows — the same rule startup uses — and write
   it to `sequence_checkpoints` for diagnostics (§3.5).
6. **Emit the shutdown event.** Write `synthetic.shutdown` with 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.
7. **Close databases.** Close every connection, writer and reader.
   SQLite checkpoints the write-ahead log automatically on close.
8. **Unmap.** Unmap every ring buffer and close the per-CPU descriptors.
9. **Exit.**

Steps 1 and 2 are deliberately split. Unlinking the pathnames stops new
senders finding the socket while the descriptors stay open, so whatever
is already queued is still readable — closing them at step 1 would
discard the queue, which is the data most recently produced and
therefore most likely to explain why the system is being stopped.

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_

> What an ungraceful termination leaves behind, the signals eventd handles, and the diagnostic dump.

## 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_

> The one failure that costs something unrecoverable, and why the whole ingestion pipeline is shaped around delaying it.

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.gap` record 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_

> What a full disk does to each of the three stores, and how corruption is detected and contained.

## 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 vanish after startup without stopping it — what survives, and what stops working.

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_token` fails on new query connections, so new queries
  are denied.
- `kacs_access_check` and `kacs_access_check_list` fail, so a query in
  progress that needs a fresh check is denied.
- Cached check results stay valid for the duration of the query that
  obtained them.
- **Event ingestion is unaffected.** Neither the drain nor the write
  path calls KACS (§8.1).
- Log 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_

> Memory, query slots, descriptors and writer stalls — how each runs out, and what eventd does when it does.

## 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_

> The failure the three stores' durability settings were chosen against, and what a restart does afterwards.

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 configuration key under the eventd registry subtree, its type and default, and how an invalid value is treated.

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. |

> [!NOTE]
> `MaxQueryMessageBytes` and the two datagram ceilings are related and
> nothing enforces the relation. A record larger than the query message
> ceiling cannot be returned and fails every query that reaches it
> (PSPU §3.15). At these defaults a producer can deposit a log line four
> times larger than any response that could carry it.

## 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):

```text
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_

> eventd's own constants — access rights, generic mapping, the field GUID namespace, data type roots and origin names.

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

```text
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_

> Implementation techniques that affect no storage format, protocol or observable behaviour, offered as guidance rather than requirement.

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_

> What peipkg is — the program that fetches, verifies and installs software — what is unusual about it, and the shape of an operation.

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_

> The scope of this manual — the package manager, the producer toolchain and the image composer — and what is documented elsewhere.

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 this manual borrows unchanged from PSPU §5, and the ones it adds for itself.

Terms defined in PSPU §5.2 — package, manifest, files manifest, payload,
repository, descriptor, index, virtual name, role, claim, holder,
installation root, trust anchor — carry the same meaning here and are
not redefined.

Terms specific to the implementation:

- **Transaction** — the atomic unit of work. Every install, upgrade,
  uninstall, grant, and revoke executes within one, even when it
  contains a single operation.

- **Plan** — the resolver's output: an ordered list of operations that,
  applied to the installed set, satisfies the request. A plan is
  computed entirely from index data, before anything is fetched.

- **Goal** (or *target*) — one requested operation the operator named:
  install this, upgrade that, remove the other.

- **Candidate** — an available package, drawn from a repository index,
  that the resolver may select.

- **World** — the resolver's working model, keyed by (name, root): every
  installed package plus every operation in flight.

- **Journal** — the record of a transaction's intent, stored as rows in
  the package database. It carries the *backup map*: for each displaced
  file, the sibling path its original was renamed to.

- **Staged file** — a file written to a temporary sibling of its
  destination, within the same directory, before the transaction
  commits. Nothing appears at a final install path until the apply
  phase.

- **Backup** — a displaced original, renamed aside within its own
  directory. Backups cost no additional disk space and are produced by a
  single rename.

- **Authorization** — a resolver output demanding an explicit,
  action-specific act from the operator before the plan may be applied.
  Distinct from a **notice**, which is informational and never blocks.

- **Adoption** — recording an existing unowned file as owned by an
  installing package, without rewriting it, when its content already
  matches what would have been installed.

- **Side effect** — one of the three standard maintenance operations a
  package may declare (PSPU §5.24).

- **Recipe** — a `pekit.toml` file plus its build script: the input to
  the producer toolchain describing how to turn an upstream source tree
  into one or more packages.

- **Lock** (in composition) — the pinned, resolved closure an image
  composition records, so that the same inputs produce the same image.
  Not to be confused with the transaction lock, which is a mutual
  exclusion primitive.

---

# 1.4 Compatibility

_Peios / Advanced Peios / peipkg / Introduction_

> Which format and protocol version peipkg implements, the architectures it supports, and how it interoperates.

## 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_

> The package manager itself — its verbs, the flags that change what a transaction may do, and how it exits.

`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_

> The producer toolchain that turns a source tree into package files, and the three facts about it that matter here.

`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_

> The tool that serves a repository — its verbs, what publishing verifies, the invariants it defends, and what it does not do.

`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_

> Assembling an image without installing anything onto the building machine, sharing peipkg's resolver and archive reader.

`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 transactional store — why that choice is load-bearing, what it holds, and how it is protected.

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.

> [!NOTE]
> An earlier design signed each commit record with a key bound to a
> dedicated package-manager principal. peipkg holds no principal of its
> own (§13.1), so there is no key to sign with; the descriptor on the
> database is the protection instead. Narrowing that descriptor so the
> journal is writable only *through* the package-manager executable
> becomes possible once an elevated-executable mechanism exists, and
> would be a tightening rather than a correction.

---

# 2.6 On-Disk State

_Peios / Advanced Peios / peipkg / The Tools_

> The four kinds of state peipkg keeps across three places — the database, repository configuration, the index cache, and staged files.

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 a file naming a base URL and the policy applied to it — the local handle and the transport it uses.

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_

> The trust ceremony where an operator decides a key speaks for a URL — the two forms, bootstrapping the freshness floor, and fetching keys before verifying.

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 repository carries a numeric priority deciding candidate selection and tie-breaks — and what priority pointedly is not.

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_

> Bringing a repository's trust state and cached index up to date — the sequence, the freshness gate, maximum trusted age, and what is not checked.

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 but are read on every operation — the cache that closes the gap, and how it is re-verified.

Indexes change when a repository publishes; peipkg reads them on every
operation. The gap between those two rates is what the cache exists for.

## 3.5.1 Structure

A fetched index and its signature are stored as content-addressed
objects, with a small pointer file naming the current object for each
repository. An older sidecar layout is still read, so a cache written by
an earlier version stays usable.

`peipkg clean` removes objects no pointer references.

## 3.5.2 Re-verification

Caching avoids re-parsing JSON. It does not avoid re-verifying
signatures. Every operation that relies on a cached index verifies its
detached signature again against the repository's current trust state.

peipkg additionally cross-checks a cached index against the freshness
state recorded in the database, and rejects one whose index version or
generation timestamp disagrees with what was recorded.

> [!NOTE]
> The two checks close different holes. Re-verifying the signature stops
> substituted metadata being trusted between the cache write and the
> next read. The cross-check against recorded state stops an older
> *validly signed* index being dropped directly into the cache, which
> would otherwise bypass the refresh path where the freshness floor is
> enforced.

## 3.5.3 When the cache fails

A cached index that fails to load or fails to verify 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 over HTTP — the size caps, URL resolution, supported schemes and failure handling.

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_

> The two relations that let one repository act on another's packages, and the gates on both.

Two relations let one repository act on another's packages, and both are
gated.

## 3.7.1 Foreign replaces

A `replaces` declared by a lower-priority repository, targeting a
package originally installed from a higher-priority one, raises an
authorization. The operator confirms it specifically; a general `--yes`
does not satisfy it, and the authorising act is audited.

> [!NOTE]
> Without the gate, a custom repository silently replacing an
> official-repository package is a routine `upgrade` away. The
> confirmation is what stops an escalation happening as a side effect of
> an operation the operator thought was maintenance.

## 3.7.2 Foreign conflicts

A `conflicts` declared by a lower-priority repository, which would cause
a higher-priority package to be removed as a cascade, is the mirror
image: a denial-of-availability rather than an escalation.

peipkg resolves a conflict by rejecting the plan outright rather than by
cascading removals, so the situation the guard describes does not arise:
the low-trust package simply fails to install, and the high-trust one
stays where it is.

## 3.7.3 A low-trust provider filling a role

When the candidate that satisfies a dependency does so through
`provides` from a lower-priority repository, while a higher-priority
repository holds a name-matching package whose constraint check failed,
peipkg raises an authorization and requires explicit confirmation. This
is the same shape as the foreign-`replaces` guard: a less-trusted
package taking over a name a more-trusted one was expected to fill.

The check runs when resolving a dependency. It does not run for a
package the operator named directly, where it cannot fire in practice
because a directly named goal carries no constraint for the
higher-priority candidate to fail.

## 3.7.4 Origin that no longer resolves

Each of these guards compares the priority of the repository a package
came from against the priority of the repository acting on it. A package
whose originating repository has since been removed 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_

> Turning "install this" into an ordered plan, entirely from index data before anything is fetched — inputs, outputs and determinism.

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_

> When a candidate satisfies a dependency — direct names and provides, constraints, roles as goals, and the architecture qualifier.

A dependency is satisfied by a candidate when the conditions of PSPU
§5.21 hold: the name matches directly or through `provides`, any
constraint is met by the appropriate version, the architecture qualifier
is met, and the candidate is in the dependency's root.

Three consequences of those rules shape how peipkg behaves.

## 4.2.1 A goal may name a role

An install goal is satisfied under the same conditions as a dependency,
with the goal's name in place of the dependency's. An operator may
therefore ask to install `sh`, `cc`, or `coreutils` and receive whatever
package provides it.

When a goal is satisfied by a candidate whose name differs from the one
the operator typed, peipkg reports the substitution. This is a notice
rather than an authorization: the operator is told, but an unattended
run is not blocked.

> [!NOTE]
> Reporting matters because the operator asked for one name and received
> a package with another. Reproducibility is not at stake — a consumer
> that records its resolved closure, as an image composition does, pins
> the chosen package, so the substitution is decided once rather than
> re-decided on every build.

A goal already satisfied by an installed *provider* is not recognised as
satisfied: peipkg checks whether the goal's name is present, not whether
something provides it. Asking to install a role that an installed
package already provides therefore installs a second provider.

## 4.2.2 Upgrade and remove are name-only

An install goal may resolve through `provides`. An upgrade, a downgrade,
and a removal do not: they act on a package already installed under a
specific name, and resolving them through `provides` would let an
upgrade substitute a different package for the one the operator named.

## 4.2.3 The architecture qualifier

The only qualifier value is `any`, and anything else is rejected. `any`
means the candidate's architecture equals the depending package's
effective architecture, or is `noarch`.

For a `noarch` depender the effective architecture is the system's
primary architecture — a script's dependency on its interpreter resolves
against the concrete system being assembled. peipkg applies that rule
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_

> The ordered rules peipkg applies when several packages satisfy one dependency, and what happens when the rules run out.

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.

1. **An exact architecture match beats `noarch`.** A candidate whose
   architecture equals the system's primary architecture is preferred
   over a `noarch` candidate of the same name.

2. **The depending package's own repository is preferred, bounded.**
   When resolving a dependency for a package D, a candidate from D's
   repository is preferred over a cross-repository one — but only when
   D's repository is at least as trusted as the alternative. When D
   comes from a lower-priority repository than the cross-repository
   candidate, this rule does not apply and rule 3 decides.

   > [!NOTE]
   > The bound is the point of the rule. Without it, a low-trust package
   > pulls in low-trust transitive dependencies that shadow higher-trust
   > alternatives, simply by virtue of being their depender.

   The rule applies when D is being installed or upgraded in this
   transaction. When D is already installed and is merely the reason a
   dependency is being resolved, peipkg does not consult the repository
   D was installed from, so the rule does not fire — and the same
   dependency can resolve to a different provider depending on whether D
   is being touched.

3. **Higher repository priority is preferred** — a lower numeric
   priority (§3.3).

4. **A higher version of the name being resolved is preferred.** That
   version is the candidate's own when it matched by name, and the
   matching `provides` entry's version when it matched through
   `provides`. A candidate matched through an unversioned `provides`
   states no version and is preferred *less* than any candidate that
   states one.

   > [!NOTE]
   > Rule 4 compares the role, not the package. Two packages filling one
   > role are unrelated software on unrelated version scales: asked
   > which `coreutils` they offer, one project's `9.9` and another's
   > `0.4` answer with the versions in their `provides` entries.
   > Comparing those as *package* versions would decide the role by
   > which project numbers its releases higher.

5. **A higher package revision is preferred** — already implied by rule
   4, retained for clarity.

6. **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_

> Every condition that makes resolution produce no plan, each with a machine-readable reason and the package it names.

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 never enters a plan automatically, and is not carried into the resolver at all.

An optional dependency is never included in a plan automatically.

peipkg does not carry optional dependencies into the resolver at all:
they are not part of the candidate model, so there is no path by which
one could be installed without being asked for. An operator who wants
one names it as a goal.

A package whose optional dependencies are absent functions correctly
with reduced capability. A package that does not function without an
"optional" dependency has mis-categorised it — that is a required
dependency wearing the wrong label.

Optional dependencies still participate in claims. A claim path declared
on an optional-dependency entry is an *optional claim consumer* (§9.6):
the dependency need not be satisfied for the declaring package to
install, and the path is materialised only if and when some eligible
provider holds the role.

> [!NOTE]
> The pattern this supports: a service that would use a log sink if one
> existed, and ships without one. It declares the sink's runtime path as
> an optional claim, installs with no provider present, and the path
> simply does not exist. Installing a provider later materialises the
> path retroactively against the declaring package's declaration;
> removing the provider again withdraws it, with no effect on the
> declaring package.

---

# 4.6 Removal Cascades

_Peios / Advanced Peios / peipkg / Resolution_

> Removing a package others depend on — blocking relations, cascading, and the system-critical packages that refuse either.

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 one root — the two resolution modes, top-level placement, and cascading across roots.

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_

> What must already be true before an install begins — the fetched file, the repository's trust state, the database and the plan.

An install begins with a package file already fetched, the repository it
came from with its trust state, the current database state, and the plan
that called for the install.

The following hold before the install proceeds:

1. The package's hash matches what the repository index recorded.
2. The package's signature, if present, verifies against the trust set
   scoped to its originating repository.
3. The package is not already installed at the same version and
   architecture in this root.
4. The package's architecture is the system's primary architecture or
   `noarch`.
5. The package's dependencies are satisfied by the installed set, by the
   plan in flight, or by both together.
6. No installed package conflicts with it.

A precondition failure aborts the install, and the transaction
containing it is rolled back.

## 5.1.1 Verification before extraction

For a transaction containing several installs or upgrades, every
package's signature and index-hash verification completes before any
package's payload is extracted.

> [!NOTE]
> This prevents a class of multi-package attack: package A is verified,
> extracted, and its contents then influence the verification or
> extraction of package B — A installs a tool B's extraction invokes, or
> A creates a directory whose descriptor decides where B's files land.
> Verifying everything first means extraction operates on a known-good
> set of payloads.

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_

> The package is decompressed, parsed and checked in full before anything is staged — including destinations, and what is not re-checked.

Before anything is staged, the package is decompressed, parsed, and
checked in full.

1. Decompress and walk the archive, enforcing the layout and ordering
   rules of PSPU §5.12.
2. Parse the manifest.
3. Parse the files manifest.
4. Check that every payload file has an entry in the files manifest and
   that every entry has a file — in both directions.
5. Check that the manifest's name, version, and architecture match what
   the plan selected.
6. Validate every payload path against PSPU §5.13 and every entry type
   against PSPU §5.12.
7. Validate every symlink's target against PSPU §5.17.
8. Validate every payload entry's destination against the permitted
   install destinations.

## 5.2.1 Destinations are checked here

Step 8 is not a duplicate of the producer's own check. A package
arriving on a target system need not have been produced by a cooperating
producer, so validation performed while packing says nothing about the
bytes about to be written. This is where the destination rules are
actually enforced.

**Ancestor directories are exempt.** An archive carries an explicit
entry for every ancestor of the content it ships, and those are
structure rather than destination claims. A directory entry is checked
only when no other payload entry sits beneath it — which is the archive
shape of an explicit empty directory, and *is* a destination claim.

**Special system packages need two keys.** A package declaring
`special_system_package` has waived the producer-side layout check. That
grants nothing here: peipkg refuses an out-of-layout payload unless the
operator also passed `--dangerously-bypass-path-restrictions`. When the
declaration arrives without the flag, the refusal names the refused
request, so that an operator can tell "this package asked for an
exemption I did not grant" from "this package is malformed".

When both keys are present, the destination check is skipped entirely
for that package, with no residual denylist. A package installed this
way can write anywhere, including under `/lcl/policy`.

## 5.2.2 What is not re-checked

The determinism rules of PSPU §5.11 constrain the archive's bytes:
ordering, modification times, ownership, mode, extended attributes, and
header format. peipkg checks entry ordering and nothing else. A package
whose entries carry a mode other than `0777`, a non-zero owner, an
`mtime` unrelated to its manifest, or extended attributes is accepted
and installed.

Because extraction ignores tar modes entirely (§5.4), such a package
behaves no differently on Peios. Extracted elsewhere with an ordinary
tar tool, it does not.

## 5.2.3 Size cross-checks

The manifest's declared installed size has to equal the sum of the file
sizes in the files manifest, and a package where the two disagree is
rejected. The decompression bounds of PSPU §5.27 are enforced
continuously during the walk, on every chunk of output.

The cap is computed from the size the *manifest* declares, plus the
fixed overhead allowance, subject to the absolute 4 GiB ceiling. The
index's declared sizes are parsed and carried onto the candidate but are
not compared against the manifest's and are not used as the bound.

---

# 5.3 Preparation

_Peios / Advanced Peios / peipkg / Installation_

> Computing what the install will do — which files and directories are created, which side effects fire, collisions and disk space.

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 processed in archive order into staged paths, with nothing at a final location yet — modes, descriptors and hashing.

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_

> How an install path is computed and used, what the final component is protected against, and what is not protected.

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.

> [!NOTE]
> The format's symlink-target rules are the first layer of the defence
> described in PSPU §5.26, and component-wise resolution against a
> pinned parent descriptor is the second. Only the first is in place.
> The second is what PSPU §5.26 requires of a conforming consumer, and
> what closes the two cases above.

---

# 5.6 Registration

_Peios / Advanced Peios / peipkg / Installation_

> Recording what the install will mean — why the manifest is stored, and how file ownership is written down.

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 that already exists without belonging to any package — what peipkg does about it, and what it is meant to do.

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 is one atomic operation combining an install and a removal — the four file categories, and their ordering at commit.

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_

> Configuration under /usr/etc is seed configuration, not effective configuration — modified detection, what is recorded, and why.

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_

> The preconditions an upgrade adds to an install's, the steps it runs, and what happens to the upgraded package's dependents.

## 6.3.1 Preconditions

Every install precondition (§5.1) holds for the new version, except that
"not already installed" is replaced by "the installed version has the
same name and architecture, in the same root, at a different version".

In addition: the new version's dependencies are satisfied, or will be by
other operations in the same transaction; no installed package depends
on the current version in a way the new version cannot satisfy; and a
downgrade carries an explicit authorisation.

## 6.3.2 The steps

1. **Validate** the new package exactly as an install does (§5.2).
2. **Diff** the old file list against the new (§6.1).
3. **Stage** every added and replaced file to a temporary sibling of its
   destination, verifying its content hash.
4. **Apply**, at commit: rename added files in; rename replaced
   originals aside and the staged files in; rename removed files aside.
5. **Schedule side effects**, including those implied by files being
   removed as well as those the new version declares.
6. **Re-register**: replace the database's record for this package with
   the new version's identity, file list, and manifest.

## 6.3.3 Dependents

An upgrade that would leave a dependent's constraints unsatisfied cannot
proceed on its own. The resolver includes the dependent's upgrade — or
its removal — in the same transaction, or the resolution fails.

---

# 6.4 Replaces

_Peios / Advanced Peios / peipkg / Upgrade and Removal_

> Supersession after a rename — the guard on a replaces-triggered upgrade, and how it interacts with 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_

> A downgrade is an upgrade to an older version with one extra precondition — plus undo, and what a version revert does not revert.

## 6.5.1 Downgrade

A downgrade is an upgrade whose new version is older than the installed
one. The procedure is identical, with one additional precondition: the
operator has explicitly authorised it.

The authorisation is raised by the resolver as an elevated action, and
is not satisfied by the routine confirmation prompt.

> [!NOTE]
> Explicit authorisation is required because a downgrade is unusual: it
> implies the operator wants to revert to a known-good earlier state,
> often after a failed upgrade. Making them opt in ensures the downgrade
> is intentional rather than the result of a misconfigured constraint.

A downgrade target has to be available from a configured repository's
archive index, or from a package file already cached. Versions pruned
from an archive cannot be reached without an externally supplied package
file.

## 6.5.2 Undo

`peipkg undo` reverts the effect of a previous transaction. It works by
re-resolving against the archive index with downgrades permitted, and
applying the resulting plan as an ordinary transaction, rather than by
restoring the previous transaction's backups.

Two consequences follow from that choice. Undo needs the archive index,
and therefore a reachable repository or a warm cache — it is not an
offline operation. And it produces a new transaction with its own
journal and its own rollback, rather than unwinding an old one, so the
guarantees are the same as any other change.

## 6.5.3 What a version revert does not revert

Reverting a package's version does not revert anything outside that
package's payload: registry state, configuration materialised by
reconcillers, runtime data under `/var/`, and any user data are
unaffected.

Comprehensive system rollback, including state under registry control,
is a higher-level concern handled by recovery snapshots. Package-level
version revert covers the common case of an update breaking something.

---

# 6.6 Uninstall

_Peios / Advanced Peios / peipkg / Upgrade and Removal_

> The preconditions and steps of a removal, what happens to directories, and how overlapping ownership is handled.

## 6.6.1 Preconditions

The named package is installed; no other installed package depends on it
unless the plan removes them too; and no other installed package's
`replaces` targets it.

Blocked removals cascade or refuse, per §4.6.

## 6.6.2 The steps

1. **Enumerate** the package's owned paths from the database.
2. **Prepare** the removal.
3. **Remove**: rename each path aside as a backup rather than deleting
   it, so that the uninstall can be rolled back.
4. **Schedule side effects** implied by what was removed.
5. **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 content no longer matches the hash recorded at install — where the check runs, and what checking costs.

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.

> [!NOTE]
> A modified file at removal time is either a customisation that removal
> destroys, or an unauthorised modification of a system file. Both are
> worth surfacing, and both currently pass silently.

## 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, with one durability boundary that everything commits across.

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_

> What may go into one transaction, how operations are ordered, the one-operation-per-package rule, and cross-root 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 per root at a time — how the exclusive lock is acquired, why staleness cannot happen, and what it does not cover.

At most one transaction is in progress at a time in a given root.

peipkg acquires an exclusive lock before beginning. A second invocation
detects the lock and fails immediately with a "transaction in progress"
message rather than waiting.

## 7.3.1 Staleness cannot happen

The lock is a `flock(2)` advisory lock on a file in the root's state
directory, held by the running process. The kernel releases it when the
process exits, however it exits — cleanly, by signal, or by being
killed.

That makes staleness impossible by construction. There is no timeout, no
liveness probe, and no process-identity comparison, because there is
nothing that could hold a lock after the holder is gone.

> [!NOTE]
> The alternative — a lock file with a recorded process identifier, a
> `kill(pid, 0) == ESRCH` liveness probe, and a timeout — is the design
> this avoids. A timeout-only staleness check
> breaks single-writer atomicity outright: a long-running but perfectly
> live transaction, a fifty-package install on slow storage, can be
> declared dead by another invocation, and both then proceed
> concurrently. An advisory lock is not a weaker version of that design;
> it is a stronger one.

A second structural guard backs it up: the database carries a partial
unique index permitting at most one transaction in the pending state, so
even a defect that bypassed the lock could not produce two concurrent
pending transactions.

## 7.3.2 What the lock does not cover

A read-only query against committed state does not take the lock. The
database provides snapshot-isolated reads, so a query sees a consistent
view of committed state as of the moment it began, regardless of a write
committing underneath it. Listing installed packages during a long
install is safe and does not block.

---

# 7.4 The Commit Procedure

_Peios / Advanced Peios / peipkg / Transactions_

> The five steps that take a transaction from uncommitted to committed, and what each one guarantees.

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 journal is rows in the package database rather than a separate file — what it holds, its integrity, attribution and versioning.

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_

> Repeated side effects are deduplicated and invoked once per transaction, after every operation completes.

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_

> Queries see only committed transactions under snapshot isolation, and the one place that boundary is softer.

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_

> peipkg checks the journal before permitting new work — why it rolls back rather than forward, and the cross-root exception.

Before permitting new work, peipkg checks the journal for a pending
transaction.

- **No pending transaction** — nothing to do.
- **A pending transaction** — roll it back. Restore every displaced
  original from the backup map, remove the directories the transaction
  created, discard its staged files, and clear it from the journal.

Recovery is itself crash-safe: every action it takes is a rename from
the backup map, and every step checks the current state before acting,
so re-running after an interruption converges on the same result.

## 7.8.1 Rollback rather than roll-forward

For a single-root transaction there is no roll-forward. Because the
database commit is the single durability boundary and is itself atomic,
a recovered transaction is only ever committed — in which case the
database says so and there is nothing to recover — or pending, in which
case it rolls back.

> [!NOTE]
> Roll-back-only recovery is possible *because* the database is a
> transactional store. A design that applies file changes after a
> separate commit-intent record needs roll-forward to finish what that
> record promised. Here the database commit and the fact that the
> transaction is done are the same atomic event, so there is nothing
> left to finish.

## 7.8.2 Cross-root: the exception

A cross-root operation commits one root at a time. Once a root has
committed, its transaction is done and cannot be undone by rolling back
a sibling.

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_

> Rollback is possible at any point before the database commit — when it happens, the procedure, and the order it undoes things in.

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

1. Any step of any operation fails — a hash does not verify, disk space
   runs out, a security descriptor is rejected.
2. The operator cancels.
3. The process terminates abnormally before commit, in which case
   rollback runs on the next invocation (§7.8).

## 8.1.2 The procedure

1. Discard the transaction's staged files.
2. Leave the pending database changes uncommitted — the commit never
   ran — and clear the transaction from the journal.
3. Restore every displaced original by renaming its backup back into
   place.
4. Remove the directories the transaction created.
5. Release the lock.

Side effects are not involved. They run only after the database commit,
so a rolled-back transaction never reached one and there is nothing to
undo.

## 8.1.3 Ordering

File operations are reversed in the opposite order to the one they were
applied in, and each step checks the current state before acting, so a
rollback interrupted partway and re-run reaches the same result.

---

# 8.2 Backups

_Peios / Advanced Peios / peipkg / Rollback and Recovery_

> A displaced original is renamed aside within its own directory and never copied — the naming scheme and the retention rules.

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 before the transaction — and what happens when rollback itself fails.

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 or corrupt journal leaves a state peipkg cannot reason about — what exists today, and what an operator can do.

A failed rollback, a corrupt journal, or an unrecoverable backup
mismatch leaves the system in a state peipkg cannot reason about.

## 8.4.1 The intended handling

A recovery mode with five properties:

1. Every write operation — install, upgrade, uninstall — is refused
   until recovery completes.
2. Read operations proceed but carry the indeterminate-state warning in
   their output.
3. A forensic report is available on demand, identifying the pending
   transaction's operations, files whose on-disk content does not match
   the recorded hash, database records inconsistent with the journal,
   and any orphaned staged files or backups.
4. An explicit resolution command accepts an operator decision: roll the
   pending transaction back, **or** accept the current on-disk state and
   discard the journal and backups.
5. Resolution is a deliberate operator action and is never performed
   automatically.

## 8.4.2 What exists

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 binary is one file among the payload, with one wrinkle.

Upgrading the package manager is not a special case.

The peipkg binary is one file among a transaction's payload, staged
beside itself and renamed into place like any other. The swap is a
single atomic rename, so there is no half-written binary to recover
from: recovery reconciles a transaction that crashed between its atomic
steps, not a file caught mid-write.

## 8.5.1 The one wrinkle

After a self-upgrade, the binary running the next recovery may be a
different version from the one that started the transaction.

This is handled by versioning the journal format. Each transaction
records the schema version it was written under. A peipkg version that
can read that schema recovers the transaction directly; one that cannot
refuses with an error naming the schema version, leaving the
transaction for a version that can.

No immutable copy of the previous binary is required. If recovery needs
the prior binary, it is already present as that binary's ordinary
backup — until the transaction commits, at which point backups are
discarded (§8.2).

> [!NOTE]
> A package-manager upgrade that commits *successfully* but installs a
> defective binary is not a recovery case: there is no incomplete
> transaction. It is handled by running the prior binary — retained as
> the transaction's backup, if the transaction has not yet committed —
> to perform an ordinary downgrade. Once the transaction has committed
> and the backups are gone, the route is a downgrade using whatever
> peipkg is now installed, or an externally supplied package file.

---

# 8.6 Reverting a Version

_Peios / Advanced Peios / peipkg / Rollback and Recovery_

> Reverting to an earlier version is an ordinary downgrade through the ordinary machinery — its procedure, constraints and scope.

Reverting an installed package to an earlier version is an ordinary
downgrade, and runs through the ordinary transaction machinery with the
ordinary guarantees.

## 8.6.1 The procedure

1. Query the archive index for the available versions of the package.
2. Select the desired one.
3. Run an upgrade with that version as the new version, which requires
   explicit downgrade authorisation (§6.5).

## 8.6.2 Constraints

The target has to be available from a configured repository's archive
index, or already cached locally. A version pruned from the archive
cannot be reached without an externally supplied package file.

Reverting may require adjusting dependents whose constraints the older
version does not satisfy. The resolver determines that, and the plan may
include further downgrades or removals.

## 8.6.3 Scope

Reverting a package's version reverts that package's payload and nothing
else. Registry state, configuration materialised by reconcillers,
runtime data under `/var/`, and user data are unaffected.

> [!NOTE]
> Comprehensive system rollback, including state under registry control,
> is a higher-level concern handled by recovery snapshots and other
> mechanisms outside the package manager. Package-level version reversion
> covers the common case of "this update broke something; put it back".

---

# 9.1 The Model

_Peios / Advanced Peios / peipkg / Roles and Claims_

> A role is a virtual name several packages contend to own, with at most one holding it — the pieces, and why the links belong to peipkg.

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_

> What makes a package an eligible provider of a role — what peipkg checks, and what it deliberately does not.

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 an eligible provider claims every unheld role it provides, and what happens when one transaction brings two providers.

Installing a package that is an eligible provider of one or more roles
claims, by default, every one of those roles that is currently
**unheld**. The new provider becomes the holder, and the role's claim
paths are materialised against its targets.

Auto-claim applies only to unheld roles. Installing a provider of a role
another package already holds does not change the holder: the new
provider is installed and eligible, and the incumbent keeps the role
unless the operator directs otherwise.

> [!NOTE]
> The two halves are deliberate. Auto-claiming an unheld role means a
> freshly installed sole provider just works — install the registry
> daemon and its binary appears, with no second command. Leaving held
> roles alone means installing an alternative provider never silently
> steals a live system name out from under the incumbent.

## 9.3.1 Two providers in one transaction

When a transaction installs two eligible providers of the same unheld
role, the role goes to the one whose package name sorts lexicographically
first.

The rule exists so that the outcome is a consequence of the inputs
rather than of the order the resolver happened to place them in, which
would make an install non-deterministic.

---

# 9.4 Install Flags

_Peios / Advanced Peios / peipkg / Roles and Claims_

> The three flags that modify what an install claims, the contradictions between them, and naming a role the package does not provide.

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.

> [!NOTE]
> The flags compose. `--no-claim --claim registryd` empties the auto set
> and forces exactly one role: it claims that role and nothing else, not
> even a second unheld role the package provides. This is the idiom for
> "claim only this".

## 9.4.1 Contradictions

Two combinations are rejected as self-contradictory: `--claim-all` with
`--claim`, and `--claim-all` with `--no-claim`.

`--no-claim` with `--claim` is not contradictory — it is the idiom
above.

## 9.4.2 Naming a role the package does not provide

A role named by `--claim` that nothing in the transaction provides is
rejected: a package cannot hold a role it is not an eligible provider
of.

The check is against every package in flight, not only the one the
operator named. So `peipkg install foo --claim bar` succeeds when a
package pulled in as a dependency of `foo` provides `bar`, and claims it
for that package.

---

# 9.5 The claim Command

_Peios / Advanced Peios / peipkg / Roles and Claims_

> Listing providers and granting a role by hand, addressed by role, with the grants that are refused.

`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.

> [!NOTE]
> Addressing by name rather than by path keeps a multi-path,
> multi-slot role coherent: one command moves the whole role, however
> many files it materialises. A path-addressed form would invite a role
> being half-moved.

---

# 9.6 Materialisation

_Peios / Advanced Peios / peipkg / Roles and Claims_

> Recomputing a role's links from current state and applying the difference — when it runs, held but unmaterialised, collisions and repointing.

Materialisation is reconciliation: peipkg recomputes what the role's
links should be from current state, and applies the difference.

## 9.6.1 When it runs

Every transaction recomputes the desired link set over **all**
post-transaction manifests and **all** holders — not only for roles
whose holder it changed.

That is what makes retroactive materialisation work. Installing a
package that merely declares a consumer-side path for a role another
package already holds creates the link against the existing holder, and
does not re-open the question of who holds it. Removing such a package
removes the links only it declared, leaving paths other packages still
declare in place.

Reconciliation is idempotent: reconciling against unchanged state
produces no filesystem change.

## 9.6.2 Held but unmaterialised

Holder state lives in its own table, keyed by role, and is never
inferred from whether a link exists.

A role whose computed claim-path set is empty materialises no links and
remains held. Installing a package that declares a path for it later
materialises the link retroactively, against the holder already on
record.

> [!NOTE]
> A sole provider installed before any consumer is held but
> unmaterialised: it holds the role, but until some package declares a
> dependency-side path — or the provider itself declares a default —
> there is no path to point anywhere. Tracking holder state
> independently of link existence is what lets the later install create
> the link without re-deciding the holder.

## 9.6.3 Collisions

Before materialising a link, peipkg checks that no installed package
owns the claim path.

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.

> [!NOTE]
> Atomic repoint matters because a claim path is typically on the
> critical path of a running system — a contended daemon binary may be
> executed at any moment. A single rename that replaces the old link
> outright closes the window; two renames leave it open for a full
> rename round-trip, on every link of the role.

---

# 9.7 Withdrawal

_Peios / Advanced Peios / peipkg / Roles and Claims_

> What happens to a role when its holder is uninstalled, why nothing is promoted automatically, and how it is ordered within the uninstall.

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.
```

> [!NOTE]
> Withdrawal leaves the role unheld rather than auto-promoting because
> the replacement is the operator's choice: the remaining providers are
> alternatives that were deliberately not chosen while the incumbent
> held the role. Auto-promoting one would silently repoint a contended
> system name at a provider nobody selected. Surfacing the alternatives
> with a ready-to-run command makes reassignment one step without making
> it implicit.

## 9.7.1 Revocation is not withdrawal

Revoking a role is the operator taking a grant away. Withdrawal is the
same end state reached automatically when the holder is removed — a
package withdrawing its own claim rather than an operator revoking a
grant.

A revoke does not surface the remaining providers the way a withdrawal
does, even though the resulting state is identical.

## 9.7.2 Whether withdrawal is safe

It depends on the role's consumers. If every installed consumer reaches
the role through an optional dependency, withdrawal breaks no required
dependency and is informational.

If a required dependency is left with no holder for a name it hard-codes,
the operator is removing something the running system needs. That is the
scenario a system-critical guard would catch, and peipkg has no such
guard (§4.6).

## 9.7.3 Ordering within an uninstall

Because claim operations ride the last staged package operation, an
uninstall of the holder deletes the target file before it deletes the
link. There is a transient window within the transaction in which the
link dangles — invisible to anything that only sees committed state, but
visible to a concurrent reader.

---

# 10.1 Named Roots

_Peios / Advanced Peios / peipkg / Installation Roots_

> A self-contained tree with its own package database — the anchor, the naming grammar, and why nesting is structural.

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_

> Named children as rows in the owning root's database, and how a root reference is resolved.

## 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 — how routing works, which verbs route, satisfier identity, and cross-root garbage collection.

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.

```json
{ "name": "peiosutils", "root": "initramfs" }
```

declares that the dependency is required in the initramfs root, wherever
the depending package lives.

## 10.3.1 Routing

Every placement decision passes through one point: given a dependency
and the depending package's root, choose the target root. An empty
`root` field, or single-root mode, keeps the dependency where the
depender is; otherwise the name is looked up in the live registry.

A `root` naming something not registered is a resolution failure —
unsatisfiable, naming the root — rather than a silent fallback.

Cross-root edges are honoured in plan ordering and in the
reverse-dependency check that decides what a removal breaks.

## 10.3.2 Which verbs route

`install` resolves cross-root. `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.

> [!NOTE]
> Cross-root dependencies are what let a root be composed through the
> dependency graph. An initramfs package depends on ordinary packages —
> a shell, core utilities — and has them placed into the initramfs root,
> either implicitly by living there or explicitly through `root`. The
> depended-on package declares no root affinity of its own; where it
> lands is the depender's and the operator's choice. This generalises
> the fixed build-host/target split other systems draw to an open set of
> named roots.

## 10.3.4 Top-level placement

Where an operator names a package directly with no explicit `--root`,
the package's `default_root` decides where it lands. peipkg applies it
once, before building the resolver's requests, and only when the
operator did not pass `--root` — a defaulted root does not count as
explicit.

If the named packages declare no default, the current root is used. If
they declare exactly one distinct default, the whole install is
re-rooted there. If they declare two or more different defaults, peipkg
stops and tells the operator to split the command or pass `--root`,
rather than picking one.

A dependency's placement is never governed by its own `default_root`;
only by the depending package's root and the dependency's `root` field.

## 10.3.5 Cross-root garbage collection

Removing the last thing in a root that required a cross-root dependency
does not remove that dependency from the other root. Cross-root
autoremoval is not implemented; the dependency stays until something
removes it explicitly.

---

# 10.4 Composing a Root

_Peios / Advanced Peios / peipkg / Installation Roots_

> Building a fresh root deterministically without touching the host or running package code — the manifest, the lock, and the three phases.

`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 install-time code; it declares one of three standard maintenance operations, and peipkg runs it.

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, and the three properties that keep a package from influencing how it runs.

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_

> Side effects run at commit after every package is registered — what failure does, and what deliberately does not schedule one.

## 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.

> [!NOTE]
> Rolling a committed transaction back because a cache rebuild exited
> non-zero would be disproportionate. The three recognised tools are
> stable with few failure modes outside system-level corruption, and
> every one of those failure modes is recoverable by running the tool
> again.

## 11.3.3 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 is a family of TOML documents rather than one file — strict parsing throughout, and how the layers merge.

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_

> The recipe's own file — environment variables, wrapping, build targets and generation targets.

`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_

> Where a build's inputs come from — version control, fetched artifacts, local trees and patches — with signature verification and the lock.

`[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 described by its own file layered over a shared base — metadata, relationships, files, symlinks, multipack and publish.

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:

```toml
libfoo = ">= 1.2"
libfoo = { constraint = ">= 1.2", root = "initramfs" }
```

A table with no constraint matches any version. The table form is the
only way to place a dependency in another root; there is no string
sugar for it.

Conflicts, provides, and replaces are plain name-to-string maps.
Conflicts deliberately carry no root: a conflict is root-local by
construction, and the consumer rejects a root on a conflicts entry
outright.

`side_effects` is a list of strings passed through verbatim: pekit does
not check membership of the recognised set, the consumer does. What
pekit *does* check is agreement with the payload, at pack time and in
both directions — see [Building and signing](/peios/advanced-peios/peipkg/producing-packages/building-and-signing.md).

## 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_

> The two mechanisms that make the shipped manifest more than what the recipe wrote — placeholder substitution and derived capabilities.

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:

```toml
libpeios = "{{version}}"
```

which pins the upstream version and leaves the revision unconstrained.

## 12.5.2 Derivation

pekit derives capabilities from the built payload and merges them on top
of what the recipe declared. A hand-written entry always wins.

**Shared libraries.** Sonames are read from the built objects: a
library's own soname becomes a provide, and a binary's needed sonames
become dependencies. Symlinks are skipped, so a versioned library and
its development link do not both claim the same soname. A
shared-library-shaped file carrying no soname produces a warning.

Where the workspace's symbol-version policy names a soname and a token
prefix, the symbol versions a binary actually references become a
version floor on that soname's dependency — so a binary using a recent
C library symbol depends on a library version that has it.

**pkg-config modules.** Each `.pc` file becomes a provide named for the
module, versioned from its version field. Its required modules become
dependencies with ordered constraints, merged from both the public and
private requirement lists. Variable references are expanded to a fixed
point. An unparseable version or constraint is dropped with a warning
rather than failing the build, and modules the package provides itself
are subtracted from what it requires.

The consequence to hold onto: a shipped manifest routinely declares
dependencies the recipe never wrote. Reading a recipe tells you what was
declared, not what was shipped.

---

# 12.6 Building and Signing

_Peios / Advanced Peios / peipkg / Producing Packages_

> Fetching, patching, building into stage directories, packing the result, signing it, and recording the corresponding source.

## 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_

> The format's determinism rules are necessary but not sufficient — what the build has to control, and how to verify a package reproduces.

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 and no rights of its own — it runs as the calling operator, and every file operation is checked against their token.

peipkg holds no principal, no identity, and no access rights of its own.

It runs **as the calling operator**. Every file operation it performs —
creating, replacing, or deleting — is checked by the kernel against the
*caller's* token and the target's security descriptor. If the caller is
authorised the operation succeeds; if not, it fails. peipkg contributes
no authority.

This is verifiable in the shape of the program. There is no daemon, no
broker, and no socket. Nothing in it changes identity: no user or group
is assumed or dropped, no capability is manipulated, no ownership is
changed on any file it writes. No privilege is requested anywhere. The
only privilege its operation touches is the one audit emission consumes
passively (§13.3).

## 13.1.1 What follows

The authority to install a package is exactly the authority to write the
directories the package installs into — an ordinary matter of security
descriptors on the permitted destinations. A deployment grants
installation authority by granting those write rights to whichever
principals it intends to be able to install software.

A package's declared security descriptor overrides can only assign
descriptors the calling operator already has the authority to assign.
Where applying a given descriptor requires a particular right —
`WRITE_DAC` for a discretionary access list, `WRITE_OWNER` and
`SeRestorePrivilege` for an owner assignment — that right is held by the
*operator*, not by peipkg. A package cannot, through
peipkg, obtain authority the operator running it does not hold.

That consequence is currently theoretical rather than load-bearing,
because overrides are parsed and then never applied (§5.4).

> [!NOTE]
> An earlier design ran the package manager as a dedicated service
> principal holding broad system rights — a posture comparable to a root
> process. Peios instead makes it an ordinary unprivileged program:
> there is no standing principal to compromise, no privilege to confine,
> and the blast radius of a malicious package is bounded by the
> authority of whoever ran it.
>
> The cost is that scoped, curated installation — letting a
> low-authority operator install one approved package without granting
> general write access — is not something peipkg can provide. That is
> the job of the higher-level roles and features layer, which can act as
> a privileged broker.

---

# 13.2 Blast Radius

_Peios / Advanced Peios / peipkg / Security_

> With no standing privileged identity there is nothing to confine and nothing to compromise — and what peipkg therefore does not do.

Because peipkg holds no principal of its own, there is no standing
privileged identity to confine and no privileged process to compromise.
The authority exercised during an install is the caller's, and only for
the duration of that invocation.

The blast radius of installing a malicious or defective package is
therefore bounded, precisely, by the authority of the operator who
installed it: **a malicious package can do nothing the operator could
not already do directly.**

That makes the trust decision — which repositories an operator
configures, and which packages an operator with broad authority chooses
to install — the operative security boundary. Signature verification and
the repository trust model exist to inform that decision. They do not
substitute for it.

## 13.2.1 What peipkg does not do

peipkg needs write access to the payload destinations and network access
to fetch from configured repositories, and nothing beyond that.

- It performs no `kexec`.
- It loads no kernel modules. The `depmod` side effect rebuilds a module
  dependency map; it does not load anything.
- It loads no BPF programs.
- It writes no registry state. Its bookkeeping is a private store, and
  the database schema says so in as many words.

The one thing that reaches outside its own state is a side effect, and
that is a fixed absolute path, with a cleared environment, from a closed
set of three (§11.2).

> [!NOTE]
> peipkg has its own thing it calls a registry: the table recording
> which named roots exist. That is a table inside the private store, not
> the system registry, and the claim above is about the latter.

---

# 13.3 Audit

_Peios / Advanced Peios / peipkg / Security_

> Every install, upgrade, uninstall, refresh and recovery emits an event through KMES — what one carries, and what emission depends on.

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.

> [!NOTE]
> peipkg's own events are a semantically meaningful summary of an
> operation. They are not the security boundary. The authoritative
> record of what changed on disk is the kernel's own audit of the
> underlying file operations, which the calling operator cannot
> suppress. A forged or omitted peipkg event cannot conceal a file
> operation from the kernel's record — which is why the gaps above are
> gaps in *diagnosis*, not in accountability.

---

# 13.4 Operator Authorisation

_Peios / Advanced Peios / peipkg / Security_

> The points requiring a deliberate act specific to the elevated action, why --yes does not satisfy them, and what is left ungated.

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 key fingerprint supplied out of band is the root of the whole repository trust model — where anchors come from, and what that means.

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_

> The checks that depend on the local system clock, and what a wrong clock does to each of them.

Several checks depend on the local system clock: a signing key's
validity window, the maximum trusted age, index staleness, and build
provenance timestamps.

An attacker able to manipulate the local clock can extend a
transitioning key's validity, evade a staleness check, or hide a
compromise-detection window.

peipkg does not gate on clock sanity, and does not pretend to. There is
no build-timestamp comparison, no time-synchronisation state query, and
no override flag for a clock peipkg thinks is wrong. The
clock-dependent checks assume a sane clock.

> [!NOTE]
> The intended end state refuses operations when the clock is plausibly
> wrong — when the current time precedes peipkg's own recorded build
> timestamp, or when no reliable time source has reported a successful
> synchronisation since boot. A *reliable* source means a
> cryptographically authenticated time protocol, or agreement among
> several independent unauthenticated ones; plain unauthenticated time
> synchronisation, being trivially substitutable by an attacker in a
> network position, does not on its own qualify.
>
> Operators in environments where clock manipulation is a concern will
> want an authenticated time source in use.

---

# 13.7 Threats Out of Scope

_Peios / Advanced Peios / peipkg / Security_

> What the package manager explicitly does not defend against, and where each of those concerns is addressed instead.

The following are explicitly outside what the package manager defends
against. Each is a real concern; each is addressed elsewhere.

**Compromise of an installing operator's identity.** peipkg runs with
the operator's authority (§13.1). If that identity is compromised, no
format-level defence helps. The kernel's own audit and recovery
mechanisms apply.

**Side-channel attacks on installed binaries.** Speculative-execution,
cache-timing, and similar attacks against installed software are the
kernel's concern and the software's, not the package format's.

**Physical attacks on storage.** A physically compromised disk can have
its package database or installed files altered offline. Hash
verification at use time — outside the package manager — is the
appropriate defence.

**Compromise of the build farm.** A compromised farm can sign malicious
packages with trusted keys. Detection requires independent
reproducible-build verification (§12.7).

The build farm identifier recorded in each manifest can be constrained
per repository as defence in depth — refusing packages from a farm not
on a configured allowlist. That is not implemented, and it would protect
only against a stale farm whose key was rotated but whose identifier is
still recognised, or against an attacker who obtained a key without the
farm's operational identity. It is not a format-level guarantee.

**Network-level censorship or denial of service** preventing package
fetching. An operational concern.

> [!NOTE]
> These exclusions are honest acknowledgements rather than weaknesses to
> dismiss. What they have in common is that each sits either below the
> package manager — where the kernel is the right layer — or above it,
> where the operator's judgement about what to trust is the only thing
> that can help.

---

# 14.1 An Interrupted Transaction

_Peios / Advanced Peios / peipkg / Failure Modes_

> Power loss or a kill partway through — what the journal preserves, what happens next, and how a post-commit crash differs.

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_

> When the rollback itself fails — what is left on disk, what peipkg does about it, and what it means for the next operation.

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_

> A file whose hash differs from the one recorded at install — what that can mean, how to tell the cases apart, and reinstalling.

`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_

> Fetch failure during a refresh or an install — why the previous trust state is retained and nothing falls back to unverified.

## 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 first — where in the operation the write fails decides how bad it is.

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 missing, dangling or pointing at the wrong thing — what each state means, and how to repair it.

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_

> Every path peipkg reads or writes — per root, transient names, composition, side-effect tools and producer files.

## 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 audit event peipkg emits — the types, their payload fields, where events do not appear, and what emission depends on.

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](/peios/using-peios/events/package-events/the-event-set.md), 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
  `optional` policy, emit no authorisation record.
- `peipkg-compose` emits 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_

> The PKM subsystems — KACS, KMES, LCS — expose static tracepoints to the standard Linux tracing stack of ftrace, perf, and eBPF.

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 a `reason` code 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](/peios/security-fundamentals/inspecting/the-event-stream.md)).
- **`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](/peios/developing-for-peios/debugging-the-kernel/kernel-tracepoints.md) 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.

> [!NOTE]
> **Safety.** PKM tracepoints never record pathnames or security-descriptor bytes. They carry inode numbers, superblock magics, resolved policies, access masks, numeric identifiers, lengths, and reason codes only. This is by design, so they are safe to leave compiled into production kernels and enable in the field.

---

# Kernel tracepoints

_Peios / Developing for Peios / Debugging the kernel_

> Enabling and reading the kacs:, kmes:, and lcs: tracepoint systems — at runtime through tracefs, at boot through the kernel command line.

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:

```sh
# every PKM tracepoint
ls /sys/kernel/tracing/events/kacs /sys/kernel/tracing/events/kmes /sys/kernel/tracing/events/lcs

# the fields (and their symbolic decodings) of one event
cat /sys/kernel/tracing/events/kacs/kacs_file_access/format
```

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:

```sh
cd /sys/kernel/tracing

echo 1 > events/kacs/enable                 # all KACS access-decision events
echo 1 > events/kacs/kacs_file_access/enable # just one

cat trace                                    # read what has accumulated
echo 1 > tracing_on                          # (on by default)
```

Because the fields are structured, you filter in the kernel rather than grepping text. To see only **denials**:

```sh
echo 'ret != 0' > events/kacs/kacs_file_access/filter
```

To watch a single inode, or one `reason`:

```sh
echo 'ino == 1234' > events/kacs/kacs_file_access/filter
echo 'reason == 3' > events/kacs/kacs_sd_cache_lookup/filter   # 3 == miss-needs-synth
```

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.

> [!NOTE]
> **Migration note.** This replaces the older `kacs.trace=1` boot parameter, which emitted a single hand-rolled `pr_info` line per KACS access decision. The equivalent today is `trace_event=kacs:* tp_printk`, which produces the same pre-userspace transcript but as structured, filterable events across all three subsystems. `kacs.trace=1` is no longer recognised.

## Reading a KACS access decision

A `kacs:` access-decision event answers "what did KACS decide about this object, and why?". The key fields:

- **`verdict`** — `allow` or `deny`, derived from `ret` (`0` is allow; a negative errno is a denial). Filter on `ret` for 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](/peios/using-peios/mount-policies/overview.md) 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](/peios/security-fundamentals/access-decisions/debugging-a-denial.md).

---

# Writing regman pages

_Peios / Developing for Peios / Documenting configuration_

> How to document your package's registry keys so regman can explain them. You ship a .regman fragment in /usr/share/regman/; this is its fenced format, the fmt/lint workflow, and the rules the tools won't catch for you.

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](/peios/using-peios/registry-administration/regman.md); 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:

```toml
[files]
"main:share/regman/exampled.regman" = "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:

```toml
[files]
"@recipe:exampled.regman" = "usr/share/regman/exampled.regman"
```

See [Packages](/pekit/recipes/packages.md) for ref resolution, and [Multi-package recipes](/pekit/recipes/multi-package.md) if one recipe produces a family of packages that each document their own keys.

> [!NOTE]
> `regman` reads shipped documentation, never the live registry. A `.regman` page describes what a setting *means and should be* — its type, default, valid range — not what some machine currently has it set to. Don't write current-state-specific prose; it won't be true on the next box.

## 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 token `regman` matches against. You do not write this by hand; `regman fmt` bakes it from `canonical` (see *The fmt/lint workflow*, below).
- **The header** — `key: value` lines, 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.

> [!NOTE]
> `regman` renders field values **verbatim** — it does not reformat them. If you want the default to read `1024 (1 K jobs)`, write exactly that into the `default:` field. The card shows what you wrote.

### 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:

```bash
regman fmt  exampled.regman    # bake the folded anchor onto every fence line
regman lint exampled.regman    # verify structure and anchors
```

The workflow is:

1. **Write each record** with its `canonical` and body. Open the record with a fence line — you can put any placeholder after the dashes, e.g. `--- x`; `regman fmt` overwrites it.
2. **Run `regman fmt`.** It rewrites every fence to `--- <fold(canonical)>` and prints `<file>: anchors updated` if anything changed. It is idempotent — a second run is a no-op. A record missing `canonical` is skipped and reported.
3. **Run `regman lint`.** It reports any record with no `canonical:` and any fence anchor that disagrees with its `canonical` (`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:

```bash
REGMAN_DIR=. regman Machine\\System\\Exampled MaxQueueDepth
REGMAN_DIR=. regman Machine\\System\\Exampled
REGMAN_DIR=. regman -k queue
```

## 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 `--- text` form that bites. If you need a horizontal rule, use `***` or `___`.
- **Keep `applies` to the vocabulary `live` / `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 `-k` results.
- **Don't invent fields.** Unknown header keys are ignored, so `defualt:` or `applies-to:` vanish without warning. There is deliberately no `access:`/SD field (the deployed Security Descriptor is live state a shipped file can't know — say access intent in prose if it matters) and no `since:` 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](/peios/using-peios/registry-administration/regman.md) — the operator's view of `regman`: reading a knob-card, the `-k` search, and the intent-not-state boundary.
- [Configuration, not storage](/peios/using-peios/registry-concepts/configuration-and-meaning.md) — *why* the registry holds values without their meaning, the idea a `.regman` page exists to serve.
- [Packages](/pekit/recipes/packages.md) and [the recipe format reference](/pekit/reference/recipe-format.md) — installing the fragment as part of your package.

---

# What is the Peios SDK

_Peios / Developing for Peios / SDK basics_

> The Peios SDK is the C-ABI library family you use to talk to Peios from your own programs — access control, the registry, and events. Its first and largest member is libpeios.

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](/peios/security-fundamentals/identity/overview.md) 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/developing-for-peios/registry-sources/overview.md).
- **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](/peios/developing-for-peios/sdk-basics/installing-and-linking.md)** — the packages, the headers, and how to build against the library.
- **[Library conventions](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md)** — 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](/peios/developing-for-peios/sdk-basics/your-first-program.md)** — a small, complete program you can compile and run.

---

# Installing and linking

_Peios / Developing for Peios / SDK basics_

> The packages that make up libpeios, the headers you include, and how to compile and link against the library — with pkg-config, by hand, statically, and from non-C languages.

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:

```c
#include <peios.h>          /* the whole API */
```

or, for a tighter compile surface, just the concept headers you use:

```c
#include <peios/security.h> /* SIDs, security descriptors, ACLs */
#include <peios/token.h>    /* tokens */
#include <peios/access.h>   /* access checks */
#include <peios/file.h>     /* file security */
#include <peios/process.h>  /* process security */
#include <peios/registry.h> /* the LCS registry */
#include <peios/msgpack.h>  /* msgpack framing */
#include <peios/event.h>    /* the KMES event stream */
```

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:

```sh
cc myprog.c $(pkg-config --cflags --libs peios) -o myprog
```

`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:

```sh
cc myprog.c -lpeios -o myprog
```

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:

```sh
cc myprog.c -o myprog /usr/lib/x86_64-linux-peios/libpeios.a
```

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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md) for buffers and errors exactly as a C caller would.

- **Rust** — use the maintained bindings: the safe **`peios`** crate (or the raw `peios-sys`), covered in [Using the SDK from Rust](/peios/developing-for-peios/sdk-basics/using-the-sdk-from-rust.md). You don't need to hand-roll `bindgen`. (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** — `ctypes` or `cffi` against `libpeios.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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md) 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](/peios/developing-for-peios/registry-sources/overview.md), you link the sibling library **librsi** instead (or as well). It is built on the same [`peios-cabi` substrate](/peios/developing-for-peios/sdk-basics/what-is-the-peios-sdk.md), follows the identical [library conventions](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md) — 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:

```c
#include <rsi.h>            /* the whole librsi API */
/* or: */
#include <rsi/source.h>    /* registration */
#include <rsi/request.h>   /* decoding requests */
#include <rsi/response.h>  /* building responses */
```

and compile with pkg-config (the descriptor's module name is `rsi`):

```sh
cc mysource.c $(pkg-config --cflags --libs rsi) -o mysource
```

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_

> A small, complete C program that parses a security descriptor, reads its owner, and formats the SID back to text — putting the two-call protocol, views, and the error model to work.

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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md) 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

```c
#include <peios/security.h>

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    /* An SDDL descriptor: owner and group are Administrators (BA), with a
       DACL granting them full access (FA). */
    const char *sddl = "O:BAG:BAD:(A;;FA;;;BA)";

    /* 1. Parse the SDDL text into self-relative SD wire bytes.
          First call probes for the size (cap == 0), then we retrieve. */
    ssize_t need = peios_sddl_parse_sd(NULL, 0, sddl);
    if (need < 0) {
        fprintf(stderr, "parse probe failed: %s\n", strerror(errno));
        return 1;
    }

    unsigned char *sd = malloc((size_t)need);
    if (!sd)
        return 1;

    ssize_t sd_len = peios_sddl_parse_sd(sd, (size_t)need, sddl);
    if (sd_len < 0) {
        fprintf(stderr, "parse failed: %s\n", strerror(errno));
        free(sd);
        return 1;
    }
    printf("security descriptor: %zd bytes\n", sd_len);

    /* 2. Parse the SD into a zero-copy view. The view borrows `sd` — it
          must stay alive and unmodified until we are done reading. */
    peios_sd_view view;
    if (peios_sd_parse(sd, (size_t)sd_len, &view) != 0) {
        fprintf(stderr, "sd parse failed: %s\n", strerror(errno));
        free(sd);
        return 1;
    }

    /* 3. Pull out the owner SID. On success `owner` points INTO `sd`. */
    const void *owner;
    size_t owner_len;
    if (peios_sd_view_owner(&view, &owner, &owner_len) != 0) {
        printf("no owner set\n");
        free(sd);
        return 0;
    }

    /* 4. Format the owner SID as its "S-1-…" string. The byte form of a
          SID is bounded, but its string form is variable, so we probe.
          A string length excludes the NUL, so allocate len + 1. */
    ssize_t slen = peios_sid_format(owner, owner_len, NULL, 0);
    if (slen < 0) {
        fprintf(stderr, "sid format probe failed: %s\n", strerror(errno));
        free(sd);
        return 1;
    }

    char *str = malloc((size_t)slen + 1);
    if (!str) {
        free(sd);
        return 1;
    }
    if (peios_sid_format(owner, owner_len, str, (size_t)slen + 1) < 0) {
        fprintf(stderr, "sid format failed: %s\n", strerror(errno));
        free(str);
        free(sd);
        return 1;
    }

    printf("owner: %s\n", str);

    /* 5. Only now free `sd` — every pointer the view gave us pointed into
          it, so it had to outlive the last read. */
    free(str);
    free(sd);
    return 0;
}
```

## Building and running

```sh
cc first.c $(pkg-config --cflags --libs peios) -o first
./first
```

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_sd` and `peios_sid_format` were each called first with a `NULL`/`0` buffer to learn the size, then again to fill a right-sized allocation. Neither could ever truncate: a too-small buffer would have returned `-1` with `ERANGE`, 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.** `owner` pointed *into* `sd`, so `sd` had to stay alive and unmodified until after the final `peios_sid_format`. Freeing it earlier would have left `owner` dangling — 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`:

```c
unsigned char sid[PEIOS_SID_MAX_BYTES];
ssize_t n = peios_sid_well_known(sid, sizeof sid, PEIOS_WKS_ADMINISTRATORS);
/* 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)](/peios/developing-for-peios/sdk-access-control/overview.md)** — the security vocabulary you just used, plus tokens, access checks, file security, and process security.
- **[The registry (LCS)](/peios/developing-for-peios/sdk-registry/overview.md)** — reading and writing Peios's configuration store.
- **[Events (KMES)](/peios/developing-for-peios/sdk-events/overview.md)** — emitting and consuming the audit/event stream.

---

# Using the SDK from Rust

_Peios / Developing for Peios / SDK basics_

> The Peios SDK ships first-class Rust bindings — the peios-sys raw FFI crate and the safe, idiomatic peios wrapper. This page is the on-ramp; the crate's rustdoc is the API reference.

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 `peios` is 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:

  ```sh
  cargo doc -p peios --open
  ```

  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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md#the-two-call-buffer-protocol) works, how an [access check](/peios/developing-for-peios/sdk-access-control/checking-access.md) reaches a verdict, the [RSI source model](/peios/developing-for-peios/registry-sources/overview.md) — 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`, `process` | [Access control](/peios/developing-for-peios/sdk-access-control/overview.md) |
  | `registry` | [The registry](/peios/developing-for-peios/sdk-registry/overview.md) |
  | `event`, `msgpack` | [Events](/peios/developing-for-peios/sdk-events/overview.md) |

  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`:

```toml
[dependencies]
peios = { git = "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](#linking-dynamic-vs-static). |
| `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](/peios/developing-for-peios/sdk-basics/your-first-program.md) — no size probes, no manual `close`, errors as `Result`:

```rust
use peios::token::{Token, TokenAccess};

fn main() -> peios::Result<()> {
    // Open my own token; the handle closes on drop.
    let me = Token::open_self(false, TokenAccess::QUERY)?;

    let sid = me.user()?;          // owned Sid, not a caller buffer + length
    let il  = me.integrity()?;     // typed IntegrityLevel, not a raw u32
    println!("running as {sid} at integrity {il:?}");

    Ok(())
}
```

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:

```rust
use std::os::fd::BorrowedFd;

fn serve(conn: BorrowedFd<'_>) -> peios::Result<()> {
    let caller = Token::open_peer(conn)?;
    let _guard = caller.impersonate_scoped()?;   // now acting as the caller
    do_work_as_caller()?;                        // any early return still reverts
    Ok(())
    // `_guard` drops here (or on the `?` above) → identity reverts automatically
}
```

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 normal `std` Rust program must use.
- **Static (`--features static`)** links `libpeios.a`. That archive is a Rust *staticlib*: it bakes in its own copy of the Rust runtime (the `panic = "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 links `std`** — collides on those symbols (`rust_begin_unwind`, `__rust_alloc_error_handler`, …). So the static path is for **C consumers** and `no_std` Rust binaries that supply no conflicting runtime. A `std` Rust consumer — including `cargo 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:

1. **Environment override** — set all three:
   - `PEIOS_LIB_DIR` — the directory containing `libpeios.{so,a}`
   - `PEIOS_INCLUDE` — the directory containing `<peios.h>`
   - `PKM_UAPI` — the directory containing `<pkm/*.h>` (referenced by `peios.h`'s signatures)
2. **pkg-config** — otherwise, if libpeios installs its `peios.pc` on `PKG_CONFIG_PATH`.

On a system where libpeios is installed from its [packages](/peios/developing-for-peios/sdk-basics/installing-and-linking.md#the-packages), pkg-config just works and you set nothing. For a **local checkout**, point the three variables at your build tree:

```sh
PEIOS_LIB_DIR=../libpeios/target/release \
PEIOS_INCLUDE=../libpeios/include \
PKM_UAPI=../pkm/uapi \
cargo build
```

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:

```sh
ln -sf libpeios.so ../libpeios/target/release/libpeios.so.0
```

(This is a checkout convenience — on a real system the packaged `libpeios.so.0` is already there.)

## The bottom line

- Depend on the **`peios`** crate; drop to `peios-sys` only 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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md) page teaches still holds underneath — the crate just wraps it in Rust idiom.

---

# Access control overview

_Peios / Developing for Peios / Access control_

> How the KACS pieces fit together from a developer's seat — identities, tokens, security descriptors, and access checks — and which part of the SDK you reach for.

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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md), 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`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#sids) |
| **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`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#building-security-descriptors) |
| **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`](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md) |
| **Access check** | The act of deciding whether a token may perform a desired access on an object, given the object's SD. | [`access.h`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md) |

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:

1. **Get the subject's token.** Usually the caller's — often via [`peios_token_open_peer`](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md#opening-and-creating-tokens) on a socket, so you learn who connected — or your own effective token (`token_fd = -1`).
2. **Get the object's security descriptor.** You either hold it already, read it off a file with [`peios_file_get_sd`](/peios/developing-for-peios/sdk-reference/sdk-files/file-h-file-security.md), or build one with a [`peios_sd_builder`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#building-security-descriptors).
3. **Run the check.** [`peios_access_check`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md#the-check) tells you whether the desired rights are granted, and exactly which subset was granted.

The [checking access](/peios/developing-for-peios/sdk-access-control/checking-access.md) 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](/peios/developing-for-peios/sdk-access-control/working-with-tokens.md).
- **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](/peios/developing-for-peios/sdk-access-control/working-with-tokens.md)** — who am I, who is calling me, and how to act as someone else.
- **[Checking access](/peios/developing-for-peios/sdk-access-control/checking-access.md)** — building a security descriptor and making a decision end to end.
- **[Securing files](/peios/developing-for-peios/sdk-access-control/securing-files.md)** — the native open and reading/writing file security descriptors.
- **[Hardening a process](/peios/developing-for-peios/sdk-access-control/hardening-a-process.md)** — turning on process mitigations.

For the exhaustive per-function detail behind any of these, the [reference section](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md) documents every symbol.

---

# Working with tokens

_Peios / Developing for Peios / Access control_

> Answer "who am I?", "who is calling me?", and "act as someone else" with the KACS token API — opening tokens, peer identity over sockets, impersonation, and dropping privilege.

A **token** is the runtime carrier of an identity, and it is a file descriptor. This guide covers the everyday token tasks; [`token.h`](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md) is the exhaustive reference for every call and field.

## Who am I?

To inspect your own identity, open your effective token and query it:

```c
int tok = peios_token_open_self(0, KACS_TOKEN_QUERY);
if (tok < 0) { /* errno */ }

unsigned char sid[PEIOS_SID_MAX_BYTES];
ssize_t n = peios_token_user(tok, sid, sizeof sid);   /* the user SID */

uint32_t il;
peios_token_integrity(tok, &il);                      /* integrity level RID */

struct peios_privilege_set privs;
peios_token_privileges(tok, &privs);                  /* held/enabled privileges */

close(tok);
```

`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](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#sid-and-attributes-arrays): read `CLASS_GROUPS` with [`peios_token_query`](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md#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:

```c
int conn = accept(listener, NULL, NULL);
int caller = peios_token_open_peer(conn);   /* QUERY | IMPERSONATE rights */
if (caller < 0) { /* errno */ }

/* 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.

```c
if (peios_token_impersonate(caller) != 0) { /* errno */ }

/* ... do the work here: file opens, access checks, etc. all run as the caller ... */

peios_token_revert();   /* back to your own identity */
close(caller);
```

Always pair `peios_token_impersonate` with [`peios_token_revert`](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md#impersonation-and-installation), 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:

```c
struct peios_token_restrict spec = {
    .privs_to_delete = KACS_SE_DEBUG_PRIVILEGE | KACS_SE_IMPERSONATE_PRIVILEGE,
    .flags           = KACS_TOKEN_RESTRICT_WRITE_RESTRICTED,
};
int weak = peios_token_restrict(my_primary, &spec);
```

The result is a strictly less-powerful token. Combined with [integrity levels](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#integrity-levels) and confinement (both set when [minting a token](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md#the-token-spec-builder)), 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](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md#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](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md#the-index-convention) for owner/primary-group references, and don't add the logon SID yourself — the kernel injects it.

## Next

- **[Checking access](/peios/developing-for-peios/sdk-access-control/checking-access.md)** — use a token to make an authorisation decision.
- **[`token.h` reference](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md)** — every token call in full.
- **[Impersonation](/peios/security-fundamentals/impersonation/overview.md)** — the operator-side model and its two gates.

---

# Checking access

_Peios / Developing for Peios / Access control_

> Make an authorisation decision end to end — build a security descriptor, run an access check against a token, and interpret the granted mask.

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`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md) and [`security.h`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md); here we put them together.

## When to use this

Reach for [`peios_access_check`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md#the-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:

```c
/* An ACL: allow OWNER full, allow VIEWERS read. */
peios_acl_builder *acl = peios_acl_builder_new();
peios_acl_builder_allow(acl, owner_sid, owner_len, KACS_ACCESS_ALL, 0);
peios_acl_builder_allow(acl, viewers_sid, viewers_len, KACS_ACCESS_READ, 0);

size_t acl_len;
const void *acl_bytes = peios_acl_builder_bytes(acl, &acl_len);

/* Wrap it in a security descriptor with an owner. */
peios_sd_builder *sd = peios_sd_builder_new();
peios_sd_builder_owner(sd, owner_sid, owner_len);
peios_sd_builder_dacl(sd, acl_bytes, acl_len);

size_t sd_len;
const void *sd_bytes = peios_sd_builder_bytes(sd, &sd_len);
```

(You can also write the descriptor as [SDDL text](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#sddl-text-codec) 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](/peios/developing-for-peios/sdk-access-control/working-with-tokens.md#who-is-calling-me), 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):

```c
struct peios_access_request req = {
    .token_fd = caller_fd,               /* or -1 for my own token */
    .sd       = sd_bytes, .sd_len = sd_len,
    .desired  = KACS_ACCESS_READ | KACS_ACCESS_WRITE,
    .mapping  = peios_file_generic_mapping,
};

uint32_t granted = 0;
int rc = peios_access_check(&req, &granted, NULL);
```

## Step 4 — interpret the result

```c
if (rc == 0) {
    /* Every desired right was granted. */
} else if (errno == EACCES) {
    /* Denied. `granted` still holds what WAS allowed — e.g. maybe READ
       succeeded but WRITE did not. Decide per-right from `granted`. */
    bool may_read  = granted & KACS_ACCESS_READ;
    bool may_write = granted & KACS_ACCESS_WRITE;
} else {
    /* A real error: bad token fd, malformed SD, etc. */
    perror("access_check");
}
```

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`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md#the-object-type-list-variant): 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`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md#audit-outputs) 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.h` reference](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md)** — every field of the request and the audit outputs.
- **[Access decisions](/peios/security-fundamentals/access-decisions/overview.md)** — 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_

> Open files the native KACS way, and read and write a file's security descriptor by path or by fd.

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`](/peios/developing-for-peios/sdk-reference/sdk-files/file-h-file-security.md).

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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md)).

## The native open

[`peios_file_open`](/peios/developing-for-peios/sdk-reference/sdk-files/file-h-file-security.md#opening-a-file) 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:

```c
struct peios_open_params p = {
    .desired_access = KACS_FILE_READ_DATA | KACS_FILE_WRITE_DATA,
    .disposition    = KACS_DISPOSITION_OPEN_IF,   /* open existing, else create */
    .sd             = creator_sd, .sd_len = creator_sd_len,   /* used only on create */
};

uint32_t status = 0;
int fd = peios_file_open(AT_FDCWD, "state.db", &p, &status);
if (fd < 0) { perror("open"); return -1; }

if (status == KACS_STATUS_CREATED) { /* we made it */ }
else                               { /* it already existed */ }
```

`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:

```c
/* Probe, allocate, read (two-call). */
ssize_t need = peios_file_get_sd(AT_FDCWD, "state.db",
                                 KACS_SECINFO_OWNER | KACS_SECINFO_DACL,
                                 NULL, 0, 0);
void *sd = malloc(need);
peios_file_get_sd(AT_FDCWD, "state.db",
                  KACS_SECINFO_OWNER | KACS_SECINFO_DACL, sd, need, 0);

/* Parse it with a security.h view. */
peios_sd_view v;
peios_sd_parse(sd, need, &v);
peios_acl_view dacl;
if (peios_sd_view_dacl(&v, &dacl) == 0) {
    unsigned n = peios_acl_view_count(&dacl);
    /* iterate ACEs … */
}
free(sd);
```

If you already hold a file fd, use the fd-targeted [`peios_fd_get_sd`](/peios/developing-for-peios/sdk-reference/sdk-files/file-h-file-security.md#by-fd) 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:

```c
/* Build an SD carrying only a DACL. */
peios_sd_builder *b = peios_sd_builder_new();
peios_sd_builder_dacl(b, new_acl, new_acl_len);
size_t sd_len; const void *sd_bytes = peios_sd_builder_bytes(b, &sd_len);

peios_file_set_sd(AT_FDCWD, "state.db", KACS_SECINFO_DACL, sd_bytes, sd_len, 0);
peios_sd_builder_free(b);
```

Because only `KACS_SECINFO_DACL` is selected, the owner, group, and SACL are left exactly as they were. The [`security.h` builders](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#building-security-descriptors) 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](/peios/developing-for-peios/sdk-access-control/checking-access.md) against the caller's token with `peios_file_generic_mapping` — no open, no side effects, just the verdict.

## Next

- **[`file.h` reference](/peios/developing-for-peios/sdk-reference/sdk-files/file-h-file-security.md)** — every parameter, plus the fd-targeted calls and mount policy.
- **[File access](/peios/security-fundamentals/file-access/overview.md)** — the operator-side model of native file security.

---

# Hardening a process

_Peios / Developing for Peios / Access control_

> Turn on process mitigations to harden your program, and understand the one-way, fail-closed semantics.

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`](/peios/developing-for-peios/sdk-reference/sdk-processes/process-h.md#setting-mitigations). This short guide covers using it well; the full flag set and semantics are in [`process.h`](/peios/developing-for-peios/sdk-reference/sdk-processes/process-h.md) and the [operator docs](/peios/security-fundamentals/process-mitigations/overview.md).

## Harden yourself at startup

The common case is a program hardening itself early in `main`, before it processes any untrusted input:

```c
#include <peios/process.h>

int main(void)
{
    /* Enforce W^X and shadow stacks; abort if either can't be turned on. */
    if (peios_process_set_mitigations(-1, KACS_MIT_WXP | KACS_MIT_SML) != 0) {
        perror("set_mitigations");
        return 1;   /* refuse to run unhardened */
    }
    /* ... the rest of the program runs with those protections on ... */
}
```

`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](/peios/security-fundamentals/process-mitigations/overview.md) (and in the Peios Kernel TRM §3.3, the Process Security Block). A couple of notes for the SDK caller:

- `KACS_MIT_ALL` is the mask of all valid bits — useful for validating input, not usually what you'd blanket-enable without thought.
- `KACS_MIT_CFI` is a legacy alias that expands to `KACS_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.h` reference](/peios/developing-for-peios/sdk-reference/sdk-processes/process-h.md)** — the call in full.
- **[Process mitigations](/peios/security-fundamentals/process-mitigations/overview.md)** — every mitigation and its threat model.

---

# Registry overview

_Peios / Developing for Peios / The registry_

> How LCS — the layered registry — works from a developer's seat: keys, values, layers and precedence, transactions, and watches.

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`](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md) 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](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md) 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](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md#deleting-and-hiding-keys).

## 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](/peios/developing-for-peios/sdk-registry/watching-and-transactions.md).

## 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**](/peios/developing-for-peios/registry-sources/overview.md). As a client you speak only the calls in [`registry.h`](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md).

## Where to go in this section

- **[Reading and writing](/peios/developing-for-peios/sdk-registry/reading-and-writing.md)** — open a key, read effective values, write to layers, do safe updates.
- **[Watching and transactions](/peios/developing-for-peios/sdk-registry/watching-and-transactions.md)** — react to changes and apply atomic multi-step edits.
- **[`registry.h` reference](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md)** — every call in full.

---

# Reading and writing

_Peios / Developing for Peios / The registry_

> Open a registry key, read its effective values, write into a layer, enumerate, and do lost-update-safe updates with compare-and-swap.

This guide covers the bread-and-butter registry operations. The full call signatures and error sets are in [`registry.h`](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md); here we string them together. Recall the registry's [descriptor-struct buffer convention](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md#the-buffer-convention-here): 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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md)).

## Opening a key

Open a key for the rights you need:

```c
int key = peios_reg_open_key(-1, "/System/MyApp", KEY_READ, 0);
if (key < 0) {
    if (errno == ENOENT) { /* not there */ }
    else if (errno == EACCES) { /* not allowed */ }
    return -1;
}
```

`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`](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md#opening-and-creating-keys) 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:

```c
struct peios_reg_value v = {0};
unsigned char data[256];
v.data = data;  v.data_cap = sizeof data;
/* leave v.layer NULL if you don't care which layer won */

if (peios_reg_query_value(key, "Timeout", 7, -1, &v) == 0) {
    /* v.type is REG_*, v.data_len bytes valid in `data`, v.sequence is
       the effective entry's sequence number. */
} else if (errno == ENOENT) {
    /* no effective value (or a tombstone masks it) */
} else if (errno == ERANGE) {
    /* data buffer too small; v.data_len holds the required size — grow & retry */
}
```

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`](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md#enumerating-values) — 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:

```c
uint32_t timeout = 30;
int rc = peios_reg_set_value(key, "Timeout", 7, REG_DWORD,
                             &timeout, sizeof timeout,
                             NULL, 0,      /* base layer */
                             -1,           /* auto-commit (no transaction) */
                             0);           /* no CAS guard */
```

Writing to a higher-precedence layer overrides lower ones without destroying them; [deleting](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md#writing-deleting-tombstoning) 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:

```c
for (;;) {
    struct peios_reg_value v = {0};
    unsigned char buf[64]; v.data = buf; v.data_cap = sizeof buf;
    if (peios_reg_query_value(key, "Counter", 7, -1, &v) != 0)
        break;                                     /* real error — don't spin */

    uint32_t n; memcpy(&n, buf, sizeof n); n++;

    int rc = peios_reg_set_value(key, "Counter", 7, REG_DWORD,
                                 &n, sizeof n, NULL, 0, -1,
                                 v.sequence);          /* CAS on the sequence */
    if (rc == 0) break;                                /* success */
    if (errno != EAGAIN) { /* real error */ break; }   /* else: retry */
}
```

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`](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md#watching-for-changes).

## Next

- **[Watching and transactions](/peios/developing-for-peios/sdk-registry/watching-and-transactions.md)** — react to changes and batch atomic edits.
- **[`registry.h` reference](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md)** — every call, field, and error.

---

# Watching and transactions

_Peios / Developing for Peios / The registry_

> React to registry changes through a pollable key fd, and apply multi-step edits atomically with transactions.

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`](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md); 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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md)).

## 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:

```c
int key = peios_reg_open_key(-1, "/System/MyApp", KEY_READ | KEY_NOTIFY, 0);

/* Watch values and subkeys, across the whole subtree. */
peios_reg_notify(key, REG_NOTIFY_VALUE | REG_NOTIFY_SUBKEY, 1 /* subtree */);

struct pollfd pfd = { .fd = key, .events = POLLIN };
for (;;) {
    poll(&pfd, 1, -1);
    if (pfd.revents & POLLIN) {
        /* read() the key fd to drain the change records, then re-read
           whatever you care about. */
        char records[512];
        ssize_t n = read(key, records, sizeof records);
        /* ... process change records ... */
    }
}
```

`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](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md#watching-for-changes). 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:

```c
int txn = peios_reg_begin_transaction();

int key = peios_reg_create_key(-1, "/System/MyApp/v2", KEY_WRITE, 0,
                               NULL /* base layer */, txn, NULL);
uint32_t one = 1;
peios_reg_set_value(key, "Enabled", 7, REG_DWORD, &one, sizeof one,
                    NULL, 0, txn, 0);
peios_reg_delete_value(key, "LegacyFlag", 10, NULL, 0, txn);

int rc = peios_reg_commit(txn);
if (rc == 0) {
    /* everything applied atomically; txn is terminal — close it */
} else if (errno == EBUSY || errno == EIO) {
    /* transient: the transaction is still active — retry the commit */
}
close(txn);
close(key);
```

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) and `EIO` (source failure) leave the transaction **active**, so you can retry `peios_reg_commit`. Only `0` (committed — the fd is now terminal) and `EINVAL` (already committed or never bound) are final. Check state at any point with [`peios_reg_txn_status`](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md#transactions).

## Backup and restore

To snapshot a key and its whole subtree, or replace one from a snapshot, use [`peios_reg_backup`](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md#backup-and-restore) 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.h` reference](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md)** — the notify filters, transaction states, and every error in full.
- **[Reading and writing](/peios/developing-for-peios/sdk-registry/reading-and-writing.md)** — the value operations you enlist in a transaction.

---

# Events overview

_Peios / Developing for Peios / Events_

> How KMES works from a developer's seat — the single event path, per-CPU ring buffers, trusted metadata, and MessagePack payloads.

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`](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md) and [`msgpack.h`](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md).

## What an event is

An event has two parts:

1. **Kernel-stamped metadata** you cannot forge — a `CLOCK_REALTIME` timestamp, 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.
2. **A payload** — a single [MessagePack](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md) 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](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md) 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 `sequence` numbers 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](/peios/developing-for-peios/sdk-events/consuming-events.md).

## Where to go in this section

- **[Emitting events](/peios/developing-for-peios/sdk-events/emitting-events.md)** — build a payload and emit, singly or in batches.
- **[Consuming events](/peios/developing-for-peios/sdk-events/consuming-events.md)** — drain the rings with the high-level reader (and, briefly, the low-level ring).
- **[`event.h`](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md)** and **[`msgpack.h`](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md)** — the exhaustive reference.

---

# Emitting events

_Peios / Developing for Peios / Events_

> Build a MessagePack payload and emit an event — singly, and in batches for high-rate producers.

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`](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md) and [`msgpack.h`](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md) are the full references. Emitting requires `SeAuditPrivilege`.

## Build the payload

Use the [MessagePack writer](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md#writer) to encode a single top-level value — typically a map of fields:

```c
peios_mp_writer *w = peios_mp_writer_new();

peios_mp_write_map(w, 2);                              /* {"user":…, "ok":…} */
peios_mp_write_str(w, "user", 4);  peios_mp_write_str(w, "alice", 5);
peios_mp_write_str(w, "ok", 2);    peios_mp_write_bool(w, true);

const void *payload;
ssize_t plen = peios_mp_writer_bytes(w, &payload);     /* validates as it borrows */
if (plen < 0) { /* EINVAL: malformed/under-filled — check peios_mp_writer_error */ }
```

`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

```c
int rc = peios_event_emit("my.app.login", 12, payload, (uint32_t)plen);
peios_mp_writer_free(w);

if (rc != 0) {
    switch (errno) {
    case EPERM:  /* no SeAuditPrivilege */          break;
    case EINVAL: /* zero-length type or bad payload */ break;
    case ENOSPC: /* payload too large */            break;
    case EAGAIN: /* rate-limited — back off */      break;
    }
}
```

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:

```c
if (peios_mp_validate(payload, plen, KMES_CONFIG_MAX_NESTING_DEPTH_DEFAULT) != 0) {
    /* reject it yourself */
}
```

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`](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md#batch-emit) emits many events in one call, so a single timestamp capture, identity capture, and consumer wake cover the whole set:

```c
struct peios_event_entry entries[3] = {
    { "my.app.a", 8, pa, pa_len },
    { "my.app.b", 8, pb, pb_len },
    { "my.app.c", 8, pc, pc_len },
};

uint32_t emitted = 0;
int rc = peios_event_emit_batch(entries, 3, &emitted);
if (rc != 0) {
    /* errno is from entries[emitted] — the first that failed.
       entries[0..emitted) were emitted; resume from `emitted`. */
}
```

`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](/peios/developing-for-peios/sdk-events/consuming-events.md)** — the other side of the pipe.
- **[`msgpack.h` reference](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md)** — the full encoder, including containers, extensions, and raw splicing.

---

# Consuming events

_Peios / Developing for Peios / Events_

> Drain the per-CPU KMES rings with the high-level reader, parse payloads, track lost events, and know when to drop to the low-level ring API.

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`](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md) has the full API. Consuming requires `SeSecurityPrivilege`.

## The reader loop

Open a reader for a CPU, then loop `next`/`wait`:

```c
peios_event_reader *r = peios_event_reader_open(cpu);
if (!r) { /* errno */ }

for (;;) {
    struct peios_event ev;
    int rc = peios_event_reader_next(r, &ev);
    if (rc == 1) {
        handle_event(&ev);                       /* got one */
    } else if (rc == 0) {
        peios_event_reader_wait(r, -1);          /* none right now — sleep */
    } else {
        break;                                   /* error */
    }
}
peios_event_reader_close(r);
```

`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](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md#reader):

```c
void handle_event(const struct peios_event *ev)
{
    /* Metadata is trustworthy — the kernel stamped it. */
    /* ev->timestamp, ev->sequence, ev->cpu_id, ev->origin_class,
       ev->process_guid, ev->effective_token_guid, ... */

    /* event_type and payload are NOT NUL-terminated — use the lengths. */
    if (ev->event_type_len == 12 &&
        memcmp(ev->event_type, "my.app.login", 12) == 0) {

        struct peios_mp_reader mp;
        peios_mp_reader_init(&mp, ev->payload, ev->payload_len);

        ssize_t pairs = peios_mp_read_map(&mp);
        for (ssize_t i = 0; i < pairs; i++) {
            const char *key; ssize_t klen = peios_mp_read_str(&mp, &key);
            /* dispatch on key; read or peios_mp_skip(&mp) the value */
        }
    }
}
```

### 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:

```c
uint32_t ncpu = 0;
for (;;) {
    uint64_t cap;
    int fd = peios_event_attach(ncpu, &cap);   /* low-level probe */
    if (fd < 0) break;                          /* EINVAL past the last CPU */
    close(fd);
    ncpu++;
}
/* 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`](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md#the-high-level-reader) 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](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md#the-low-level-ring) 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.h` reference](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md)** — the full reader and ring APIs, and every `struct peios_event` field.
- **[Auditing](/peios/security-fundamentals/auditing/overview.md)** — the operator-side view of the event and audit stream.

---

# Registry sources overview

_Peios / Developing for Peios / Registry sources_

> What a registry source is, how the RSI protocol flows, and the shape of a source's serve loop — the provider side of the registry, built with librsi.

A **registry source** is a storage backend for the [LCS registry](/peios/developing-for-peios/sdk-registry/overview.md). It is the other half of the registry story: where a [client](/peios/developing-for-peios/sdk-registry/overview.md) 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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md) 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:

```mermaid
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:

1. **Register.** Your process declares which *hives* (registry subtrees) it backs and registers with the kernel, receiving a **source fd**. This needs `SeTcbPrivilege`.
2. **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, and `write(2)` back a framed **response**.
3. **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:

```c
for (;;) {
    ssize_t n = rsi_read_request(src_fd, buf, sizeof buf);   /* read a frame */
    if (n == 0) break;                                       /* EOF: closing */
    if (n < 0) { /* errno */ break; }

    struct rsi_request req;
    rsi_parse_request(buf, n, &req);                         /* split header/payload */

    switch (req.op_code) {                                   /* dispatch */
        case RSI_LOOKUP:     /* decode, resolve, rsi_respond_lookup */    break;
        case RSI_SET_VALUE:  /* decode, store,   rsi_respond_status */    break;
        /* … one case per op you support … */
    }
}
```

The three headers map onto the three steps:

- **[`<rsi/source.h>`](/peios/developing-for-peios/sdk-reference/sdk-rsi-source/rsi-source-h-becoming-a-source.md)** — registering and getting the source fd.
- **[`<rsi/request.h>`](/peios/developing-for-peios/sdk-reference/sdk-rsi-request/rsi-request-h-decoding-requests.md)** — reading and decoding requests.
- **[`<rsi/response.h>`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md)** — 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](/peios/developing-for-peios/sdk-reference/sdk-rsi-request/rsi-request-h-decoding-requests.md) 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](/peios/developing-for-peios/registry-sources/registering-a-source.md)** — declare your hives and get the source fd.
- **[Serving requests](/peios/developing-for-peios/registry-sources/serving-requests.md)** — the read/parse/dispatch/decode loop in full.
- **[Building responses](/peios/developing-for-peios/registry-sources/building-responses.md)** — status-only and payload-bearing replies.

---

# Registering a source

_Peios / Developing for Peios / Registry sources_

> Declare the hives your source backs and register with the kernel to obtain the source fd.

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`](/peios/developing-for-peios/sdk-reference/sdk-rsi-source/rsi-source-h-becoming-a-source.md); 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:

```c
struct rsi_hive hive = {
    .name     = "MyStore",
    .name_len = 7,
    .flags    = 0,                    /* global hive */
    .root_guid = { /* 16-byte GUID of the hive root key */ },
    /* scope_guid left zero for a global hive */
};
```

The two decisions per hive:

- **Global or private?** A **global** hive (`flags = 0`, `scope_guid` zero) is visible system-wide. A **private** hive (`flags = RSI_HIVE_PRIVATE`, `scope_guid` non-zero) is scoped — only tokens carrying that scope GUID in their [LCS credentials](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md#lcs-registry-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:

```c
int src = rsi_register(&hive, 1, /*max_sequence=*/0);
if (src < 0) {
    perror("rsi_register");   /* EPERM if you lack SeTcbPrivilege */
    return -1;
}
/* `src` is the source fd — serve the RSI protocol on it. */
```

Two things to get right:

- **`SeTcbPrivilege` is required.** Registration is a trusted operation; without the privilege you get `EPERM`. Sources run as trusted system components.
- **`max_sequence` is 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 passes `0`; 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](/peios/developing-for-peios/sdk-reference/sdk-rsi-request/rsi-request-h-decoding-requests.md) and [response](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md) 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](/peios/developing-for-peios/registry-sources/serving-requests.md).

## Next

- **[Serving requests](/peios/developing-for-peios/registry-sources/serving-requests.md)** — the loop that runs on the source fd.
- **[`rsi/source.h` reference](/peios/developing-for-peios/sdk-reference/sdk-rsi-source/rsi-source-h-becoming-a-source.md)** — every field and error.

---

# Serving requests

_Peios / Developing for Peios / Registry sources_

> The source serve loop end to end — read a framed request, parse its header, dispatch on the op-code, and decode the payload with its typed parser.

With a [source fd in hand](/peios/developing-for-peios/registry-sources/registering-a-source.md), 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`](/peios/developing-for-peios/sdk-reference/sdk-rsi-request/rsi-request-h-decoding-requests.md) and [`rsi/response.h`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md).

## The loop skeleton

```c
unsigned char buf[64 * 1024];   /* size generously — a short buffer is EMSGSIZE */

for (;;) {
    ssize_t n = rsi_read_request(src_fd, buf, sizeof buf);
    if (n == 0) break;                       /* EOF — the source is closing */
    if (n < 0) { perror("read_request"); break; }

    struct rsi_request req;
    if (rsi_parse_request(buf, n, &req) != 0) continue;   /* EBADMSG — skip */

    switch (req.op_code) {
        case RSI_LOOKUP:    handle_lookup(src_fd, &req);    break;
        case RSI_SET_VALUE: handle_set_value(src_fd, &req); break;
        /* … a case per op you support … */
        default:
            /* Unknown or unsupported op: reply with a non-OK status
             * (RSI_TXN_NOT_SUPPORTED for transaction ops you don't implement). */
            rsi_respond_status(src_fd, &req, RSI_INVALID);
            break;
    }
}
```

Three details in that skeleton matter:

- **`rsi_read_request` blocks** until a request is queued, and returns **`0` at EOF** — that's your exit. Size `buf` generously; a frame larger than `buf` returns `-1`/`EMSGSIZE`.
- **`rsi_parse_request` gives you `req.op_code`** to dispatch on, plus `req.request_id` (which every reply must echo — the helpers do this for you) and `req.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`:

```c
void handle_lookup(int fd, const struct rsi_request *req)
{
    struct rsi_lookup q;
    if (rsi_request_lookup(req, &q) != 0) {          /* EBADMSG */
        rsi_respond_status(fd, req, RSI_INVALID);
        return;
    }

    /* q.parent_guid (by value), q.child_name / q.child_name_len (borrowed).
       Resolve the child in your storage across its layers … */

    /* … then reply with the resolved path entries + metadata: */
    rsi_respond_lookup(fd, req, entries, entry_count, metadata, metadata_count);
}
```

And for a `SET_VALUE`, a status-only op:

```c
void handle_set_value(int fd, const struct rsi_request *req)
{
    struct rsi_set_value v;
    if (rsi_request_set_value(req, &v) != 0) {
        rsi_respond_status(fd, req, RSI_INVALID);
        return;
    }

    /* Honour the CAS guard before storing. */
    if (v.expected_sequence != 0 && current_seq(&v) != v.expected_sequence) {
        rsi_respond_status(fd, req, RSI_CAS_FAILED);
        return;
    }
    store_value(&v);                                  /* your storage */
    rsi_respond_status(fd, req, RSI_OK);
}
```

## 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](/peios/developing-for-peios/registry-sources/building-responses.md)** — choosing and filling the right reply.
- **[`rsi/request.h` reference](/peios/developing-for-peios/sdk-reference/sdk-rsi-request/rsi-request-h-decoding-requests.md)** — every op's decoder and struct.

---

# Building responses

_Peios / Developing for Peios / Registry sources_

> Reply to RSI requests the right way — status-only for most ops and failures, payload-bearing helpers for the five ops that return data, and the validation contract to respect.

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`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md); this guide is the working version.

## The rule

> **On failure, always [`rsi_respond_status`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md#status-only-responses). On success, `rsi_respond_status` too — 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:

```c
/* Success for a status-only op: */
rsi_respond_status(fd, req, RSI_OK);

/* Any op reporting a problem: */
rsi_respond_status(fd, req, RSI_NOT_FOUND);   /* e.g. a missing key */
rsi_respond_status(fd, req, RSI_CAS_FAILED);  /* 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:

```c
struct rsi_value_entry values[] = {
    { .value_name = "Timeout", .value_name_len = 7,
      .layer_name = "base",    .layer_name_len = 4,
      .value_type = REG_DWORD, .data = &timeout, .data_len = 4,
      .sequence = 42 },
    /* … one per effective value you hold … */
};

rsi_respond_query_values(fd, req, values, 1, /*blankets=*/NULL, 0);
```

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 be `NULL` only when its length/count is zero.
- **Booleans** (`volatile_key`, `symlink`, target types) are strictly `0` or `1`.
- **Hidden path targets** (`RSI_PATH_TARGET_HIDDEN`) carry an **all-zero** `target_guid`.
- **`LOOKUP` / `ENUM_CHILDREN` metadata** must exactly cover the GUID targets in your path entries — every referenced key present, none missing, no duplicates, nothing unreferenced.
- **`DELETE_LAYER` orphan 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.h` reference](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md)** — every helper, struct, and error in full.
- **[Serving requests](/peios/developing-for-peios/registry-sources/serving-requests.md)** — the loop these replies live in.

---

# 1.1 Library conventions

_Peios / Developing for Peios / SDK Reference / Library Conventions_

> The handful of conventions that hold across every function in every libpeios module — learn them once and the rest reads as intent.

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_

> The three return shapes an entry point can have, and how each tells you where to read the result and how to detect failure.

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
- **`0`** on success, when it performs an action with no handle to hand back; and
- **`-1` on failure**, with the reason in `errno`.

```c
int fd = peios_token_open_self(/* … */);
if (fd < 0) {
    /* errno is set — perror(), strerror(errno), etc. */
}
```

### 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
- **`-1` on failure**, with the reason in `errno`.

These are the functions that use the [two-call buffer protocol](/peios/developing-for-peios/sdk-reference/sdk-conventions/the-two-call-buffer-protocol.md) 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.

```c
uint32_t granted = 0;
int rc = peios_access_check(/* … */, &granted);
/* 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_

> The getxattr-style protocol every variable-length function follows — measure with a null buffer, then call again with one big enough.

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 a **`NULL` buffer**) 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 `ERANGE` and 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:

```c
/* 1. Probe for the size. */
ssize_t need = peios_sid_format(sid, sid_len, NULL, 0);
if (need < 0) { /* errno set */ }

/* 2. Allocate. For a string, add 1 for the NUL. */
char *buf = malloc(need + 1);

/* 3. Retrieve. */
ssize_t n = peios_sid_format(sid, sid_len, buf, need + 1);
if (n < 0) { /* errno set */ }
/* 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 — the builder pattern for constructing buffers, and the view pattern for reading them.

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:

1. **They are sticky-error.** The incremental `add`/`set` calls return `void` — 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, or `0` if all is well), or notice that taking the bytes fails. This lets you write a long, clean sequence of `add` calls without a conditional after every line.

2. **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.

3. **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* — `NULL` if the sticky error is set — and write the length through an optional `len_out` pointer. Each is paired with a **`_finish()`** that copies the buffer into a caller-supplied buffer using the [two-call protocol](/peios/developing-for-peios/sdk-reference/sdk-conventions/the-two-call-buffer-protocol.md) above, for when the bytes must outlive the builder.
   - **`peios_token_builder_bytes` and `peios_mp_writer_bytes`** are shaped the other way round: they *return the length* as an `ssize_t` (`-1` with `errno` on a latched error) and write the borrowed pointer through an out-parameter (which may be `NULL` to get just the length). Neither has a `_finish()` — copy the borrowed bytes yourself if they need to outlive the builder.

A typical builder lifecycle:

```c
peios_acl_builder *b = peios_acl_builder_new();   /* NULL on OOM */
peios_acl_builder_allow(b, sid, sid_len, mask, 0); /* void — no check */
peios_acl_builder_deny(b, other, other_len, mask, 0);

size_t len;
const void *acl = peios_acl_builder_bytes(b, &len); /* NULL if errored */
if (!acl) { int err = peios_acl_builder_error(b); /* handle */ }
/* … use `acl` before the next mutation … */

peios_acl_builder_free(b);
```

### 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:

1. **You allocate the view; it is stack-friendly.** A view type such as `peios_sd_view` is 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.

2. **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.

```c
peios_sd_view sd;                        /* on the stack */
if (peios_sd_parse(buf, buf_len, &sd) != 0) { /* EINVAL */ }

const void *owner; size_t owner_len;
if (peios_sd_view_owner(&sd, &owner, &owner_len) == 0) {
    /* `owner` points INTO `buf` — valid only while `buf` lives. */
}
```

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_

> Every handle libpeios opens is a raw int file descriptor you close yourself, with the ordinary semantics that implies.

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 rename the kernel's wire constants — where each set comes from, and why the spelling matches the headers.

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_

> The whole convention set on one page, as a reference to come back to while reading the module chapters.

| 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](/peios/developing-for-peios/sdk-basics/your-first-program.md), 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_

> What security.h covers — SIDs, access masks, ACLs, descriptors, views, the SDDL codec and inheritance — and the conventions it assumes.

`<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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md): `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](/peios/developing-for-peios/sdk-reference/sdk-security/sids.md)** — build, parse, format, and compare security identifiers.
- **[ACLs and security descriptors](/peios/developing-for-peios/sdk-reference/sdk-security/building-acls.md)** — assemble them with builders.
- **[Parsing](/peios/developing-for-peios/sdk-reference/sdk-security/parsing-views.md)** — read them back with zero-copy views.
- **[SDDL and inheritance](/peios/developing-for-peios/sdk-reference/sdk-security/sddl-text-codec.md)** — 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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md)** — the error, buffer, builder, and view rules this page builds on.
- **[SIDs](/peios/security-fundamentals/identity/sids.md)** and **[Security descriptors](/peios/security-fundamentals/security-descriptors/overview.md)** — the operator-side concepts behind this vocabulary.
- **[`<peios/token.h>`](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md)**, **[`<peios/file.h>`](/peios/developing-for-peios/sdk-reference/sdk-files/file-h-file-security.md)**, **[`<peios/access.h>`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md)** — the KACS interfaces that consume this vocabulary, including the generic-mapping tables `peios_access_map_generic` expects.

---

# 2.2 SIDs

_Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors_

> Constructing, formatting and inspecting SIDs, the well-known set, and the integrity-level helpers.

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](/peios/security-fundamentals/identity/sids.md). 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.

```c
#define PEIOS_SID_MAX_BYTES 68u
```

### 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`](#integrity-levels)). |
| `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`](#well-known-sids). |

```c
ssize_t peios_sid_build(void *out, size_t cap, uint64_t id_authority,
                        const uint32_t *sub_auths, unsigned count);
ssize_t peios_sid_parse_string(void *out, size_t cap, const char *sddl);
ssize_t peios_sid_integrity(void *out, size_t cap, uint32_t level_rid);
ssize_t peios_sid_logon(void *out, size_t cap, uint64_t session_id);
ssize_t peios_sid_well_known(void *out, size_t cap, enum peios_wks which);
```

`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. |

```c
ssize_t  peios_sid_format(const void *sid, size_t len, char *out, size_t cap);
bool     peios_sid_valid(const void *sid, size_t len);
size_t   peios_sid_length(const void *sid);
bool     peios_sid_equal(const void *a, size_t alen, const void *b, size_t blen);
uint32_t peios_sid_rid(const void *sid, size_t len);
```

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:

```c
enum peios_wks {
    PEIOS_WKS_NULL,                 /* S-1-0-0    Nobody */
    PEIOS_WKS_EVERYONE,             /* S-1-1-0    World */
    PEIOS_WKS_LOCAL,                /* S-1-2-0    Local */
    PEIOS_WKS_CREATOR_OWNER,        /* S-1-3-0 */
    PEIOS_WKS_CREATOR_GROUP,        /* S-1-3-1 */
    PEIOS_WKS_OWNER_RIGHTS,         /* S-1-3-4    suppresses owner WRITE_DAC */
    PEIOS_WKS_ANONYMOUS,            /* S-1-5-7 */
    PEIOS_WKS_SELF,                 /* S-1-5-10   PRINCIPAL_SELF */
    PEIOS_WKS_AUTHENTICATED_USERS,  /* S-1-5-11 */
    PEIOS_WKS_SYSTEM,               /* S-1-5-18   Local System */
    PEIOS_WKS_LOCAL_SERVICE,        /* S-1-5-19 */
    PEIOS_WKS_NETWORK_SERVICE,      /* S-1-5-20 */
    PEIOS_WKS_ADMINISTRATORS,       /* S-1-5-32-544 */
};
```

For the meaning of each principal, see [Well-known principals](/peios/security-fundamentals/identity/well-known-principals.md).

### 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:

```c
enum peios_integrity_level {
    PEIOS_IL_UNTRUSTED = 0,
    PEIOS_IL_LOW       = 4096,
    PEIOS_IL_MEDIUM    = 8192,
    PEIOS_IL_HIGH      = 12288,
    PEIOS_IL_SYSTEM    = 16384,
};
```

These are the labels that appear in a SACL as a `SYSTEM_MANDATORY_LABEL` ACE (see [`peios_acl_builder_label`](/peios/developing-for-peios/sdk-reference/sdk-security/building-acls.md#adding-aces)).

---

# 2.3 Access masks

_Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors_

> A 32-bit set of rights, the four generic bits that stand in for concrete ones, and how a generic mapping resolves them.

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.

```c
uint32_t peios_access_map_generic(uint32_t mask,
                                  const struct kacs_generic_mapping *m);
```

`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>`](/peios/developing-for-peios/sdk-reference/sdk-files/file-h-file-security.md)) and `peios_token_generic_mapping` (from [`<peios/token.h>`](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md)). 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_

> Assembling an ordered list of ACEs with an ACL builder — adding each ACE type, and taking the serialised bytes.

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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md#memory-ownership): the adders return `void`, the first error latches, and you check `peios_acl_builder_error` at the end.

```c
typedef struct peios_acl_builder peios_acl_builder;

peios_acl_builder *peios_acl_builder_new(void);   /* NULL on OOM */
void               peios_acl_builder_free(peios_acl_builder *b);
void               peios_acl_builder_reset(peios_acl_builder *b);
```

`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.

```c
void peios_acl_builder_allow(peios_acl_builder *b, const void *sid, size_t len,
                             uint32_t mask, uint8_t flags);
void peios_acl_builder_deny (peios_acl_builder *b, const void *sid, size_t len,
                             uint32_t mask, uint8_t flags);
void peios_acl_builder_audit(peios_acl_builder *b, const void *sid, size_t len,
                             uint32_t mask, uint8_t flags);
```

| 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:

```c
void peios_acl_builder_label(peios_acl_builder *b, uint32_t integrity_rid,
                             uint32_t policy_mask);
```

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:

```c
struct peios_ace_spec {
    uint8_t       type;      /* KACS_ACE_TYPE_* */
    uint8_t       flags;     /* KACS_ACE_FLAG_* */
    uint32_t      mask;
    const void   *sid;       /* trustee */
    size_t        sid_len;
    const uint8_t *object_type;            /* 16-byte GUID, or NULL */
    const uint8_t *inherited_object_type;  /* 16-byte GUID, or NULL */
    const void   *app_data;  /* trailing callback/resource data */
    size_t        app_data_len;
};

void peios_acl_builder_add(peios_acl_builder *b, const struct peios_ace_spec *ace);
```

Fill in only the fields the `type` uses; leave the rest `NULL`/`0`:

- **Object ACEs** (`KACS_ACE_TYPE_*_OBJECT`) read `object_type` and `inherited_object_type` — each a 16-byte GUID, or `NULL` when absent.
- **Callback and resource-attribute ACEs** carry trailing `app_data` (which is `NULL` only when `app_data_len` is `0`). For callback ACEs this is the conditional-expression bytecode you can produce with [`peios_sddl_parse_condition`](/peios/developing-for-peios/sdk-reference/sdk-security/sddl-text-codec.md#conditional-expressions).

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

```c
const void *peios_acl_builder_bytes(peios_acl_builder *b, size_t *len_out);
ssize_t     peios_acl_builder_finish(peios_acl_builder *b, void *buf, size_t cap);
int         peios_acl_builder_error(const peios_acl_builder *b);
```

- `peios_acl_builder_bytes` borrows: it returns a pointer into the builder (valid until the next mutation, `_reset`, or `_free`), writing the length to `len_out` if non-`NULL`. It returns `NULL` if the sticky error is set.
- `peios_acl_builder_finish` copies the serialised ACL out using the two-call protocol.
- `peios_acl_builder_error` returns the latched errno, or `0` if 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_

> Binding an owner, group, DACL, SACL and control flags into one self-relative buffer with the descriptor builder.

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.

```c
typedef struct peios_sd_builder peios_sd_builder;

peios_sd_builder *peios_sd_builder_new(void);
void              peios_sd_builder_free(peios_sd_builder *b);
void              peios_sd_builder_reset(peios_sd_builder *b);
```

### 2.5.0.1 Setting components

```c
void peios_sd_builder_owner(peios_sd_builder *b, const void *sid, size_t len);
void peios_sd_builder_group(peios_sd_builder *b, const void *sid, size_t len);
void peios_sd_builder_control(peios_sd_builder *b, uint16_t set, uint16_t clear);
void peios_sd_builder_dacl(peios_sd_builder *b, const void *acl, size_t len);
void peios_sd_builder_dacl_null(peios_sd_builder *b);
void peios_sd_builder_sacl(peios_sd_builder *b, const void *acl, size_t len);
```

- **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_control` sets the bits in `set` and clears those in `clear` (`KACS_SD_DACL_PROTECTED`, and friends). You do **not** manage `SELF_RELATIVE` or the `*_PRESENT` bits — 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:

```c
const void *peios_sd_builder_bytes(peios_sd_builder *b, size_t *len_out);
ssize_t     peios_sd_builder_finish(peios_sd_builder *b, void *buf, size_t cap);
int         peios_sd_builder_error(const peios_sd_builder *b);
```

`_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_

> Reading a descriptor, ACL or ACE without copying — the caller-allocated view structs and what each exposes.

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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md#memory-ownership). 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.

```c
typedef struct peios_sd_view        { uint64_t _opaque[8]; } peios_sd_view;
typedef struct peios_acl_view       { uint64_t _opaque[4]; } peios_acl_view;
typedef struct peios_ace_view       { uint64_t _opaque[4]; } peios_ace_view;
typedef struct peios_sid_array_view { uint64_t _opaque[4]; } 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

```c
int      peios_sd_parse(const void *sd, size_t len, peios_sd_view *out);
uint16_t peios_sd_view_control(const peios_sd_view *v);
int      peios_sd_view_owner(const peios_sd_view *v, const void **sid, size_t *len);
int      peios_sd_view_group(const peios_sd_view *v, const void **sid, size_t *len);
int      peios_sd_view_dacl(const peios_sd_view *v, peios_acl_view *out);
int      peios_sd_view_sacl(const peios_sd_view *v, peios_acl_view *out);
```

`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:

```c
int      peios_acl_parse(const void *acl, size_t len, peios_acl_view *out);
unsigned peios_acl_view_count(const peios_acl_view *a);
int      peios_acl_view_ace(const peios_acl_view *a, unsigned i, peios_ace_view *out);
```

`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:

```c
unsigned n = peios_acl_view_count(&dacl);
for (unsigned i = 0; i < n; i++) {
    peios_ace_view ace;
    peios_acl_view_ace(&dacl, i, &ace);
    /* inspect ace … */
}
```

Each ACE is read through its own accessors:

```c
uint8_t  peios_ace_view_type(const peios_ace_view *e);
uint8_t  peios_ace_view_flags(const peios_ace_view *e);
uint32_t peios_ace_view_mask(const peios_ace_view *e);
int      peios_ace_view_sid(const peios_ace_view *e, const void **sid, size_t *len);
int      peios_ace_view_object_type(const peios_ace_view *e, const uint8_t **guid16);
int      peios_ace_view_inherited_object_type(const peios_ace_view *e,
                                              const uint8_t **guid16);
int      peios_ace_view_app_data(const peios_ace_view *e, const void **data,
                                 size_t *len);
```

| 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`](/peios/developing-for-peios/sdk-reference/sdk-security/sddl-text-codec.md#conditional-expressions). |

### 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:

```c
int      peios_sid_array_parse(const void *blob, size_t len, peios_sid_array_view *out);
unsigned peios_sid_array_count(const peios_sid_array_view *a);
int      peios_sid_array_get(const peios_sid_array_view *a, unsigned i,
                             const void **sid, size_t *len, uint32_t *attrs);
```

`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_

> Converting between the binary forms and human-readable SDDL text, entirely in userspace, including conditional expressions.

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.

```c
ssize_t peios_sddl_parse_sd(void *out, size_t cap, const char *sddl);
ssize_t peios_sddl_format_sd(char *out, size_t cap, const void *sd, size_t sd_len);
```

- `peios_sddl_parse_sd` parses SDDL text (e.g. `"O:SYG:BAD:(A;;FA;;;BA)"`) into self-relative SD wire bytes.
- `peios_sddl_format_sd` renders SD wire bytes back to a NUL-terminated SDDL string (length excludes the `NUL`, so allocate `len + 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:

```c
ssize_t peios_sddl_parse_condition(void *out, size_t cap, const char *expr);
ssize_t peios_sddl_format_condition(char *out, size_t cap, const void *artx, size_t len);
```

- `peios_sddl_parse_condition` compiles an expression such as `@User.Title == "PM"` into the bytecode you place in a callback ACE's `app_data`.
- `peios_sddl_format_condition` renders bytecode back to text (with no outer parentheses), length excluding the `NUL`.

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_

> Computing a child object's ACEs from its parent's inheritable ones, in userspace, with both helper entry points.

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.

```c
ssize_t peios_sd_reinherit(void *out, size_t cap, const void *parent_sd,
                           size_t parent_len, const void *child_sd,
                           size_t child_len, int is_container);
ssize_t peios_sd_strip_inherited(void *out, size_t cap, const void *sd,
                                 size_t sd_len, uint32_t info);
```

**`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_

> What token.h covers — opening, creating and adjusting tokens, querying them, and logon sessions — plus the constants it assumes.

`<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>`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#parsing-views).

The module divides into: **opening & creating**, the **token-spec builder**, **query**, **adjust/transform**, and **logon sessions**.

## 3.1.1 See also

- **[`<peios/security.h>`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md)** — the SID/ACL/SD vocabulary and the views used to parse group and privilege query payloads.
- **[`<peios/access.h>`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md)** — checking access with a token fd.
- **[Tokens](/peios/security-fundamentals/tokens/overview.md)** and **[Impersonation](/peios/security-fundamentals/impersonation/overview.md)** — the operator-side model.

---

# 3.2 Opening and creating tokens

_Peios / Developing for Peios / SDK Reference / token.h — Tokens_

> The entry points that hand back a token fd — opening your own, another process's, or minting a new one.

Each of these returns a token fd (or `-1` with `errno`).

```c
int peios_token_open_self(unsigned flags, uint32_t access);
int peios_token_open_process(int pidfd, uint32_t access);
int peios_token_open_thread(int pidfd, int tid, uint32_t access);
int peios_token_open_peer(int conn_fd);
int peios_token_create_raw(const void *spec, size_t len);
```

| 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 | IMPERSONATE` rights (no `access` argument). |
| `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` (unknown `flags`; empty or unknown `access` bits), `EACCES` (the token's own SD denies `access`).
- **`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 unknown `access` bits).
- **`peios_token_open_thread`** — the `_open_process` set, plus `ESRCH` (thread exited, or not in `pidfd`'s process) and `EINVAL` (`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_

> Assembling the token wire format with typed setters instead of by hand — core and advanced fields, flags, claims and credentials.

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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md#memory-ownership): the setters return `void`, the first error latches, you check `peios_token_builder_error` at the end, and you `_free` every builder.

```c
typedef struct peios_token_builder peios_token_builder;

peios_token_builder *peios_token_builder_new(void);
void                 peios_token_builder_free(peios_token_builder *b);
void                 peios_token_builder_reset(peios_token_builder *b);
```

### 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

```c
void peios_token_builder_user(peios_token_builder *b, const void *sid, size_t len);
void peios_token_builder_add_group(peios_token_builder *b, const void *sid,
                                   size_t len, uint32_t attrs);
void peios_token_builder_privileges(peios_token_builder *b, uint64_t present,
                                    uint64_t enabled);
void peios_token_builder_type(peios_token_builder *b, uint8_t type, uint8_t imp_level);
void peios_token_builder_integrity(peios_token_builder *b, uint32_t rid);
void peios_token_builder_session(peios_token_builder *b, uint64_t session_id);
void peios_token_builder_owner_index(peios_token_builder *b, uint32_t index);
void peios_token_builder_primary_group_index(peios_token_builder *b, uint32_t index);
void peios_token_builder_default_dacl(peios_token_builder *b, const void *acl, size_t len);
```

| 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`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#integrity-levels)). |
| `_session` | The logon session id the token references. |
| `_owner_index` / `_primary_group_index` | Which SID (by [index](#the-index-convention)) 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`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#building-acls)). |

### 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.

```c
void peios_token_builder_mandatory_policy(peios_token_builder *b, uint32_t bits);
void peios_token_builder_projected_ids(peios_token_builder *b, uint32_t uid, uint32_t gid);
void peios_token_builder_expiration(peios_token_builder *b, uint64_t when);
void peios_token_builder_source(peios_token_builder *b, const char name[8],
                                uint64_t source_id);
void peios_token_builder_audit_policy(peios_token_builder *b, uint32_t bits);
void peios_token_builder_add_restricted_sid(peios_token_builder *b, const void *sid,
                                            size_t len, uint32_t attrs);
void peios_token_builder_add_device_group(peios_token_builder *b, const void *sid,
                                          size_t len, uint32_t attrs);
void peios_token_builder_confinement(peios_token_builder *b, const void *sid, size_t len);
void peios_token_builder_supp_gids(peios_token_builder *b, const uint32_t *gids,
                                   unsigned count);
```

| 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:

```c
struct peios_token_flags {
    bool write_restricted;
    bool user_deny_only;
    bool isolation_boundary;
    bool confinement_exempt;
};
void peios_token_builder_flags(peios_token_builder *b, const struct peios_token_flags *f);
```

- `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.

```c
struct peios_token_claim_value {
    uint64_t    scalar;   /* INT64 / UINT64 / BOOLEAN (0 or 1) */
    const void *bytes;    /* STRING (UTF-8) / SID / OCTET */
    size_t      len;
};

struct peios_token_claim {
    const char *name;         /* UTF-8; transcoded to UTF-16LE on the wire */
    uint16_t    value_type;   /* KACS_CLAIM_TYPE_* */
    uint32_t    flags;        /* KACS_CLAIM_ATTR_* */
    const struct peios_token_claim_value *values;
    unsigned    value_count;
};

void peios_token_builder_add_user_claim(peios_token_builder *b,
                                        const struct peios_token_claim *claim);
void peios_token_builder_add_device_claim(peios_token_builder *b,
                                          const struct peios_token_claim *claim);
```

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.

```c
struct peios_token_lcs_credentials {
    const uint8_t (*scope_guids)[16];    /* array of 16-byte GUIDs, each non-nil & unique */
    unsigned    scope_count;             /* <= KACS_TOKEN_LCS_MAX_SCOPE_GUIDS */
    const char *const *private_layers;   /* UTF-8 names, 1..255 bytes, no '/' or '\\', unique */
    unsigned    private_layer_count;     /* <= KACS_TOKEN_LCS_MAX_PRIVATE_LAYERS */
};
void peios_token_builder_lcs_credentials(peios_token_builder *b,
                                         const struct peios_token_lcs_credentials *creds);
```

Setting it replaces any prior credentials; it is emitted as the last token-spec section. See [`<peios/registry.h>`](/peios/developing-for-peios/sdk-reference/sdk-registry-api/registry-h-the-registry-lcs.md) for what layers and scopes mean.

### 3.3.0.7 Finishing the builder

```c
ssize_t peios_token_builder_bytes(peios_token_builder *b, const void **out);
int     peios_token_builder_create(peios_token_builder *b);
int     peios_token_builder_error(const peios_token_builder *b);
```

- `peios_token_builder_bytes` returns the serialised length and, if `out` is 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_create` does it in one step: serialise and mint, returning the new token fd. This is the usual call. It requires `SeCreateTokenPrivilege`.
- `peios_token_builder_error` returns the latched errno, or `0`.

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`.

```c
peios_token_builder *tb = peios_token_builder_new();
peios_token_builder_user(tb, user_sid, user_len);
peios_token_builder_add_group(tb, admins_sid, admins_len, KACS_SE_GROUP_ENABLED);
peios_token_builder_type(tb, KACS_TOKEN_TYPE_PRIMARY, 0);
peios_token_builder_integrity(tb, PEIOS_IL_MEDIUM);
peios_token_builder_session(tb, session_id);

int tok = peios_token_builder_create(tb);       /* -1 on failure */
if (tok < 0) { int e = peios_token_builder_error(tb); /* or errno */ }
peios_token_builder_free(tb);
```

---

# 3.4 Query

_Peios / Developing for Peios / SDK Reference / token.h — Tokens_

> Reading a token's contents by information class — the generic reader, and the typed wrappers over the common classes.

You read a token's contents by **information class**. The generic reader handles any class getxattr-style; typed convenience wrappers cover the common ones.

```c
ssize_t peios_token_query(int fd, uint32_t info_class, void *buf, size_t cap);
ssize_t peios_token_user(int fd, void *sid_buf, size_t cap);   /* CLASS_USER */
```

- `peios_token_query` reads the class `info_class` (`KACS_TOKEN_CLASS_*`) into `buf` using the [two-call protocol](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md#the-two-call-buffer-protocol). Classes that return SID arrays or ACLs are parsed afterward with the [`<peios/security.h>` views](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#parsing-views) — e.g. read `CLASS_GROUPS` into a buffer, then `peios_sid_array_parse` it.
- `peios_token_user` is the same two-call read specialised to the user SID (`CLASS_USER`): probe with `sid_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`:

```c
struct peios_privilege_set {
    uint64_t present;
    uint64_t enabled;
    uint64_t enabled_by_default;
    uint64_t used;
};

int peios_token_type(int fd, uint32_t *out);            /* CLASS_TYPE */
int peios_token_session_id(int fd, uint32_t *out);      /* CLASS_SESSION_ID */
int peios_token_integrity(int fd, uint32_t *level_rid_out); /* CLASS_INTEGRITY_LEVEL */
int peios_token_privileges(int fd, struct peios_privilege_set *out); /* 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_

> Changing a token in place or deriving a new one — privileges and groups, duplicate and restrict, impersonation and linked 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

```c
int peios_token_adjust_privileges(int fd, const struct kacs_priv_entry *entries,
                                  unsigned count, uint64_t *prev_enabled);
int peios_token_reset_privileges(int fd);
int peios_token_adjust_groups(int fd, const struct kacs_group_entry *entries,
                              unsigned count, uint64_t *prev_state);
int peios_token_reset_groups(int fd);
```

- `peios_token_adjust_privileges` enables/disables the privileges named in `entries` (each a `kacs_priv_entry`); if `prev_enabled` is non-`NULL` it receives the prior enabled mask, so you can restore it later. `peios_token_reset_privileges` restores `enabled := enabled_by_default`. Errors: `EACCES` (handle lacks `ADJUST_PRIVILEGES`), `EINVAL` (empty or oversized batch, duplicate entry, enabling an absent privilege, unknown attribute bits), `EFAULT` (bad entries pointer).
- `peios_token_adjust_groups` is the group analogue. `prev_state`, if non-`NULL`, points at a caller array of `KACS_TOKEN_GROUP_MASK_WORDS` `uint64_t` words that receives the prior enabled bitmask. `peios_token_reset_groups` restores the default group state. Errors: `EACCES` (handle lacks `ADJUST_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

```c
int peios_token_duplicate(int fd, uint32_t access, uint8_t type, uint8_t imp_level);

struct peios_token_restrict {
    uint64_t           privs_to_delete;
    const uint32_t    *deny_group_indices;   /* groups demoted to deny-only */
    unsigned           deny_count;
    const void *const *restrict_sids;        /* added restricting SIDs */
    const size_t      *restrict_sid_lens;
    unsigned           restrict_count;
    uint32_t           flags;                /* KACS_TOKEN_RESTRICT_WRITE_RESTRICTED */
};
int peios_token_restrict(int fd, const struct peios_token_restrict *spec);
```

- `peios_token_duplicate` copies the token, returning a new fd with handle rights `access`, token `type` (`KACS_TOKEN_TYPE_*`), and impersonation level `imp_level` (`KACS_IMLEVEL_*`). This is how you turn a primary token into an impersonation token, or narrow a handle's rights. Errors: `EACCES` (handle lacks `DUPLICATE`, or the new token's SD denies `access`), `EINVAL` (unknown `type`/`imp_level`, raising an impersonation token's level, empty or unknown `access` bits), `ENOMEM` (allocation failed).
- `peios_token_restrict` creates a **filtered** token — the sandboxing primitive. It can delete privileges (`privs_to_delete`), demote groups to deny-only (`deny_group_indices`, by [index](/peios/developing-for-peios/sdk-reference/sdk-tokens/the-token-spec-builder.md#the-index-convention)), add restricting SIDs (`restrict_sids`/`restrict_sid_lens`), and set `KACS_TOKEN_RESTRICT_WRITE_RESTRICTED`. The result is a strictly less-powerful token you can hand to less-trusted code. Errors: `EACCES` (handle lacks `DUPLICATE`), `EINVAL` (duplicate or out-of-range deny index, malformed restricting SID, unknown `flags`, `NULL` spec or arrays), `ENOMEM` (allocation failed).

### 3.5.0.3 Impersonation and installation

```c
int peios_token_install(int fd);
int peios_token_impersonate(int fd);
int peios_token_revert(void);
```

- `peios_token_install` makes this **primary** token the calling process's primary token. Errors: `EACCES` (handle lacks `ASSIGN_PRIMARY`, or `SeAssignPrimaryTokenPrivilege` missing), `EINVAL` (not a primary token), `EAGAIN` (thread set changed mid-install — retry), `ENOMEM` (allocation failed).
- `peios_token_impersonate` makes this **impersonation** token the calling thread's effective identity — subsequent access checks on that thread run as the impersonated identity. Errors: `EACCES` (handle lacks `IMPERSONATE`), `EINVAL` (not an impersonation token), `EPERM` (restricted→unrestricted same-user — the one hard deny), `ENOMEM` (allocation failed).
- `peios_token_revert` undoes 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 of `peios_token_impersonate` — always pair them, ideally with `revert` in 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

```c
int peios_token_link(int elevated_fd, int filtered_fd, uint64_t session_id);
int peios_token_get_linked(int fd);
int peios_token_adjust_default(int fd, const void *dacl, size_t len,
                               uint16_t owner_index, uint16_t group_index);
int peios_token_set_session_id(int fd, uint32_t session_id);
```

- `peios_token_link` links an elevated + filtered primary-token pair in `session_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_linked` opens the linked token of `fd`, returning a new fd. Errors (`_link`): `EACCES` (`SeTcbPrivilege` missing, or either handle lacks `DUPLICATE`), `EINVAL` (self-link, role/session/user-SID mismatch, not primary tokens, unknown `session_id`, or an fd that is not a token fd), `EBADF` (invalid fd). Errors (`_get_linked`): `EACCES` (handle lacks `QUERY`), `ENOENT` (not part of a linked pair, or the pair was destroyed), `ENOMEM` (allocation failed).
- `peios_token_adjust_default` replaces the token's default DACL and/or owner/primary-group indices. `dacl == NULL` leaves the DACL unchanged (and ignores `len`); `dacl != NULL` with `len == 0` clears it; an index of `0xFFFF` leaves that index unchanged. Errors: `EACCES` (handle lacks `ADJUST_DEFAULT`), `EINVAL` (out-of-range index; malformed or oversized DACL), `EFAULT` (bad DACL pointer).
- `peios_token_set_session_id` sets the token's session id (requires `SeTcbPrivilege`). Errors: `EACCES` (handle lacks `ADJUST_SESSIONID`, or `SeTcbPrivilege` missing).

---

# 3.6 Logon sessions

_Peios / Developing for Peios / SDK Reference / token.h — Tokens_

> The lightweight kernel bookkeeping a token references, and the entry points for creating and destroying one.

A **logon session** is the lightweight kernel bookkeeping a token references — the "login" a token belongs to. Creating and destroying them requires `SeTcbPrivilege`.

```c
struct peios_session_spec {
    uint8_t     logon_type;     /* KACS_LOGON_TYPE_* */
    const char *auth_package;   /* UTF-8; may be "" */
    const void *user_sid;
    size_t      user_sid_len;
};

int peios_session_create(const struct peios_session_spec *spec, uint64_t *id_out);
int peios_session_destroy_empty(uint64_t session_id);
```

- `peios_session_create` creates a logon session of type `logon_type` (`KACS_LOGON_TYPE_*` — interactive, network, service, …) for `user_sid`, attributing it to `auth_package`. `id_out` is mandatory and receives the new session id, which you then pass to `peios_token_builder_session`. Errors: `EPERM` (`SeTcbPrivilege` missing), `EINVAL` (`NULL` spec, `id_out`, or field; malformed SID; oversized spec), `EFAULT` (bad pointer), `ENOMEM` (allocation failed).
- `peios_session_destroy_empty` destroys 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` (`SeTcbPrivilege` missing), `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_

> The canonical generic-to-specific rights mapping for the token object class, exported for you to pass to an access check.

```c
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`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#access-masks) or as the `mapping` in a [`peios_access_request`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md#the-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_

> What access.h is for, the two things worth knowing before using it, and the conventions it assumes.

`<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_check` tells 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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md#structured-results-out-parameters), a denied check returns `-1` with `errno == 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>`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md)** — building the security descriptors and reading the generic-mapping tables this check consumes.
- **[`<peios/token.h>`](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md)** — obtaining the `token_fd` to check, and `peios_token_generic_mapping`.
- **[Access decisions](/peios/security-fundamentals/access-decisions/overview.md)** — 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_

> The single struct describing every check — the core fields an ordinary check needs, and the advanced ones beneath them.

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.

```c
struct peios_access_request {
    int      token_fd;   /* -1 = the caller's effective token */
    const void *sd;      /* the object's security descriptor (wire bytes) */
    size_t   sd_len;
    uint32_t desired;    /* desired access mask */
    struct kacs_generic_mapping mapping;   /* the object class's mapping */

    /* ---- [adv] ---- */
    const void *self_sid;        /* PRINCIPAL_SELF substitution; NULL to omit */
    size_t   self_sid_len;
    uint32_t privilege_intent;   /* backup/restore intent bits */
    const struct kacs_object_type_entry *object_tree;
    uint32_t object_tree_count;
    const void *local_claims;    /* @Local claim array */
    size_t   local_claims_len;
    uint32_t pip_type;           /* 0 = use the subject's PSB */
    uint32_t pip_trust;
    const void *audit_context;   /* opaque object id for audit events */
    size_t   audit_context_len;
};
```

### 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>`](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md). |
| `sd` / `sd_len` | The object's security descriptor, as self-relative wire bytes — typically from a [`peios_sd_builder`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#building-security-descriptors) 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`](/peios/developing-for-peios/sdk-reference/sdk-access/the-object-type-list-variant.md). |
| `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_

> Running the full AccessCheck pipeline from userspace, and how the granted mask and audit outputs come back.

```c
int peios_access_check(const struct peios_access_request *req,
                       uint32_t *granted, struct peios_access_audit *audit);
```

Runs the full AccessCheck pipeline. Returns:

- **`0`** if *every* right in `desired` is granted;
- **`-1` with `errno == EACCES`** if any desired right is denied;
- **`-1` with another errno** on a real error (e.g. `EBADF` for a bad `token_fd`, `EINVAL` for 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](/peios/developing-for-peios/sdk-reference/sdk-access/audit-outputs.md).

```c
struct peios_access_request req = {
    .token_fd = -1,                      /* my own effective token */
    .sd = sd_bytes, .sd_len = sd_len,
    .desired = KACS_ACCESS_READ | KACS_ACCESS_WRITE,
    .mapping = peios_file_generic_mapping,
};

uint32_t granted = 0;
int rc = peios_access_check(&req, &granted, NULL);
if (rc == 0) {
    /* both READ and WRITE granted */
} else if (errno == EACCES) {
    /* denied; `granted` shows what WAS allowed (maybe READ only) */
} else {
    /* error: perror("access_check") */
}
```

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_

> The audit struct a check can fill in — continuous audit masks, staging mismatch, and what each field means.

```c
struct peios_access_audit {
    uint32_t continuous_audit;   /* OR of matching alarm masks */
    int      staging_mismatch;   /* 1 if the staged CAAP result differs */
};
```

When you pass a non-`NULL` `audit`, the check reports:

- `continuous_audit` — the OR of the alarm masks of any `SYSTEM_AUDIT` ACEs that matched, i.e. what a continuous-audit consumer would log for this access.
- `staging_mismatch` — `1` if 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](/peios/security-fundamentals/central-access-policies/overview.md) 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_

> Checking a whole object type list in one call, with a per-node result for each entry.

```c
int peios_access_check_list(const struct peios_access_request *req,
                            struct kacs_node_result *results, uint32_t count);
```

`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_count` are **mandatory** here — they describe the tree of `kacs_object_type_entry` nodes to evaluate.
- `results` receives **one `kacs_node_result` per node, in preorder**, and `count` **must equal** `req->object_tree_count`.
- Returns `0` / `-1` (`EINVAL` if `count` doesn'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_

> Opening files with an explicit rights mask, reading and writing their descriptors, and the mount policy that covers filesystems without native storage.

`<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>`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md).

## 5.1.1 See also

- **[`<peios/security.h>`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md)** — building the creator SDs and parsing the SDs these calls return.
- **[`<peios/access.h>`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md)** — evaluating a file SD with `peios_file_generic_mapping`.
- **[File access](/peios/security-fundamentals/file-access/overview.md)** and **[Mount policies](/peios/using-peios/mount-policies/overview.md)** — the operator-side model of native file security.

---

# 5.2 Opening a file

_Peios / Developing for Peios / SDK Reference / file.h — File Security_

> The open params struct — desired access, disposition, share mode and the rest — and what the call returns.

```c
struct peios_open_params {
    uint32_t    desired_access; /* KACS_FILE_* | standard | generic (strict-mode) */
    uint32_t    disposition;    /* KACS_DISPOSITION_* */
    uint32_t    options;        /* KACS_CREATE_OPT_* */
    uint32_t    flags;          /* AT_SYMLINK_NOFOLLOW | KACS_BACKUP_INTENT | KACS_RESTORE_INTENT */
    const void *sd;             /* creator SD on create, else NULL */
    size_t      sd_len;
};

int peios_file_open(int dirfd, const char *path,
                    const struct peios_open_params *p, uint32_t *status_out);
```

`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`).

```c
struct peios_open_params p = {
    .desired_access = KACS_FILE_READ_DATA | KACS_FILE_WRITE_DATA,
    .disposition    = KACS_DISPOSITION_OPEN_IF,   /* open or create */
    .options        = 0,
    .sd             = creator_sd, .sd_len = creator_sd_len,
};
uint32_t status = 0;
int fd = peios_file_open(AT_FDCWD, "data.bin", &p, &status);
if (fd < 0) { /* errno */ }
/* 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_

> Getting and setting a file's descriptor by path or by fd, with the secinfo mask that selects which components are touched.

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](/peios/security-fundamentals/file-access/managing-file-security.md)):

| 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

```c
ssize_t peios_file_get_sd(int dirfd, const char *path, uint32_t secinfo,
                          void *buf, size_t cap, uint32_t at_flags);
int     peios_file_set_sd(int dirfd, const char *path, uint32_t secinfo,
                          const void *sd, size_t len, uint32_t at_flags);
```

- `peios_file_get_sd` reads the `secinfo`-selected components of `path`'s SD into `buf`, getxattr-style ([two-call protocol](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md#the-two-call-buffer-protocol) — probe with `cap == 0`, and a too-small non-zero buffer fails `ERANGE` without truncating). `at_flags` accepts `AT_SYMLINK_NOFOLLOW`. Errors: `EACCES` (component right missing), `EINVAL` (`SACL` + `LABEL` together; `NULL` path, or `NULL` buffer with non-zero `cap`), `ERANGE` (non-probe buffer too small), `ENOENT` (path doesn't exist), `ELOOP` (no-follow and symlink).
- `peios_file_set_sd` writes the `secinfo` components of `sd` onto `path`, **preserving the components you did not select**. So to change only the DACL, build an SD with a DACL, pass `secinfo = KACS_SECINFO_DACL`, and the owner/group/SACL are untouched. Errors: `EACCES` (component right missing), `EPERM` (owner-SID validation failed without `SeRestorePrivilege`; label raised without `SeRelabelPrivilege`; MANDATORY attribute removed without `SeTcbPrivilege`), `EINVAL` (malformed SD, `SACL` + `LABEL` together, `NULL` or zero-length `sd`), `ENOENT`, `ELOOP`.

### 5.3.0.2 By fd

```c
ssize_t peios_fd_get_sd(int fd, uint32_t secinfo, void *buf, size_t cap);
int     peios_fd_set_sd(int fd, uint32_t secinfo, const void *sd, size_t len);
```

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_

> How KACS treats a superblock that cannot store native descriptors, and the entry points for reading and setting that policy.

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`.

```c
struct peios_mount_policy {
    uint32_t    policy;      /* KACS_MOUNT_POLICY_* */
    uint32_t    flags;
    uint32_t    generation;
    const void *template_sd;
    size_t      template_sd_len;
};

int peios_mount_get_policy(int fd, struct peios_mount_policy *out,
                           void *tmpl_buf, size_t tmpl_cap);
int peios_mount_set_policy(int fd, const struct peios_mount_policy *p);
```

- `peios_mount_get_policy` reads the policy for `fd`'s superblock into `out`. The template SD is returned into your `tmpl_buf` getxattr-style: on success `out->template_sd` points **into `tmpl_buf`** when that buffer was large enough, or is `NULL` if the superblock has no template. A `NULL` template buffer (or `tmpl_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 in `out->template_sd_len`, and leaves `out->template_sd` `NULL` so you can size a retry. Errors: `EPERM` (`SeTcbPrivilege` missing), `EBADF` (bad fd), `EINVAL` (`NULL` `out`, or `NULL` `tmpl_buf` with non-zero `tmpl_cap`), `EFAULT` (bad buffer pointer), `ENOMEM` (allocation failed).
- `peios_mount_set_policy` installs `p` as the superblock's policy. `policy` is a `KACS_MOUNT_POLICY_*` value; `template_sd`/`template_sd_len` supply the template SD when the policy calls for one. `flags` and `generation` must be zero on set — the kernel manages the generation counter itself and rejects a non-zero input. Errors: `EPERM` (`SeTcbPrivilege` missing), `EINVAL` (unknown or unmanaged `policy`, non-zero `flags`/`generation`, malformed or oversized template, `NULL` template 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_

> The canonical generic-to-specific rights mapping for the file object class, exported for you to pass to an access check.

```c
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`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md#access-masks), or as the `mapping` in a [`peios_access_request`](/peios/developing-for-peios/sdk-reference/sdk-access/access-h-access-checks.md#the-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_

> Complete reference for <peios/process.h> — setting process mitigation controls on the process security block (PSB).

`<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

```c
int peios_process_set_mitigations(int pidfd, uint32_t mitigations);
```

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 == -1` targets the **calling** process, which is the common case: a program hardens itself early in startup. Targeting *another* process requires `PROCESS_SET_INFORMATION` on 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`.

```c
/* Harden the current process: enforce W^X and shadow-stack, refuse to
   proceed if either can't be activated. */
if (peios_process_set_mitigations(-1, KACS_MIT_WXP | KACS_MIT_SML) != 0) {
    perror("set_mitigations");
    /* nothing was changed; decide whether to continue unhardened or abort */
}
```

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>`](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md)** — PIP dominance is determined by the subject's token; process targeting other than self depends on it.
- **[Process mitigations](/peios/security-fundamentals/process-mitigations/overview.md)** — 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_

> The registry client — opening keys, reading and writing values, enumerating, watching, securing, backing up and running transactions.

`<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**](/peios/developing-for-peios/registry-sources/overview.md). 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>`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md)** — building and parsing the SDs that secure keys.
- **[Library conventions](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md)** — the base error and buffer rules the descriptor reads specialise.
- **[The registry](/peios/using-peios/registry-concepts/overview.md)** — 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_

> Where the registry API departs from the library-wide two-call protocol, and what to do instead.

Most of libpeios returns variable-length data with an `ssize_t` and the [two-call protocol](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md#the-two-call-buffer-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 `*_cap` fields (in) and `*_len` fields (out), plus buffer pointers.
- On success it returns `0` and writes the actual length into each `*_len`.
- If a buffer is too small it returns `-1` with `errno == ERANGE` and writes the **required** length into the matching `*_len` — so a **zero-capacity buffer probes the size**.
- A `NULL` buffer is valid only with zero capacity; `NULL` with a nonzero capacity is `EINVAL`.
- For a read with two buffers, `ERANGE` is 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_

> Opening an existing key or creating one, relative to a parent fd, with a desired-access mask.

```c
int peios_reg_open_key(int parent_fd, const char *path, uint32_t desired_access,
                       uint32_t flags);
int peios_reg_create_key(int parent_fd, const char *path, uint32_t desired_access,
                         uint32_t flags, const char *layer, int txn_fd,
                         uint32_t *disposition_out);
```

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_key`** opens an *existing* key. `flags` may be `REG_OPEN_LINK` to open a symlink key *itself* rather than following it. Errors: `ENOENT`, `EACCES`, `EINVAL`, `ELOOP`, `ENAMETOOLONG`, `ETIMEDOUT`, `EIO`, `ENOMEM`.
- **`peios_reg_create_key`** opens an existing key or creates a new one. `flags` may combine `REG_OPTION_VOLATILE` (a key that does not survive reboot) and `REG_OPTION_CREATE_LINK` (create a symlink key). `layer` names the target layer to create in (NUL-terminated), or `NULL` for the base layer. `txn_fd` enlists the create in a [transaction](/peios/developing-for-peios/sdk-reference/sdk-registry-api/transactions.md), or `-1` to auto-commit. `disposition_out`, if non-`NULL`, receives `REG_CREATED_NEW` or `REG_OPENED_EXISTING`. Errors add `ENOSPC` and `EPERM` (privileged symlink creation) to the set above.

---

# 7.4 Values

_Peios / Developing for Peios / SDK Reference / registry.h — The Registry_

> Reading, writing, deleting and tombstoning a value, and enumerating a key's values — with names, types and layers.

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

```c
struct peios_reg_value {
    uint64_t sequence;  /* out: effective entry's sequence number */
    void    *data;      /* in:  buffer for the value data (NULL to probe) */
    void    *layer;     /* in:  buffer for the layer name (NULL to probe/skip) */
    uint32_t type;      /* out: value type (REG_*) */
    uint32_t data_cap;  /* in */   uint32_t data_len;  /* out: actual/required */
    uint32_t layer_cap; /* in */   uint32_t layer_len; /* out: actual/required */
};

int peios_reg_query_value(int key_fd, const void *name, uint32_t name_len, int txn_fd,
                          struct peios_reg_value *v);
```

`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

```c
int peios_reg_set_value(int key_fd, const void *name, uint32_t name_len, uint32_t type,
                        const void *data, uint32_t data_len, const void *layer,
                        uint32_t layer_len, int txn_fd, uint64_t expected_seq);
int peios_reg_delete_value(int key_fd, const void *name, uint32_t name_len,
                           const void *layer, uint32_t layer_len, int txn_fd);
int peios_reg_blanket_tombstone(int key_fd, const void *layer, uint32_t layer_len,
                                int set, int txn_fd);
```

- **`peios_reg_set_value`** writes value `name` of `type` into a specific `layer` (`NULL`/`0` = base). `type` may be `REG_TOMBSTONE` to place a *per-value* tombstone that masks lower layers. `expected_seq` is a **compare-and-swap guard**: `0` disables it; otherwise the write applies only if the value's current sequence matches, else `EAGAIN`. This is how you do lost-update-safe read-modify-write — read the `sequence` from `peios_reg_query_value`, then set with `expected_seq` set to it. Errors: `EINVAL`, `EAGAIN`, `ENOSPC`, `ENAMETOOLONG`, `EPERM`, `EACCES`.
- **`peios_reg_delete_value`** removes *a layer's* entry for `name` (`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_tombstone`** sets (`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. `set` must be `0` or `1` (else `EINVAL`).

### 7.4.0.3 Enumerating values

```c
int peios_reg_query_values_batch(int key_fd, int txn_fd, void *buf, uint32_t cap,
                                 uint32_t *len_out, uint32_t *count_out);

struct peios_reg_enum_value {
    void    *name;      /* in:  buffer for the value name (NULL to probe) */
    void    *data;      /* in:  buffer for the value data (NULL to probe) */
    uint32_t type;      /* out */
    uint32_t name_cap;  /* in */  uint32_t name_len;  /* out: actual/required */
    uint32_t data_cap;  /* in */  uint32_t data_len;  /* out: actual/required */
};
int peios_reg_enum_value(int key_fd, uint32_t index, int txn_fd,
                         struct peios_reg_enum_value *v);
```

Two ways to read every effective value of a key:

- **`peios_reg_query_values_batch`** reads them all into one `buf` in 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]`, for `count` records. `len_out` receives the bytes written (or the required size on `ERANGE`); `count_out` receives the record count. Both may be `NULL`.
- **`peios_reg_enum_value`** reads one value at a time by `index`, dense over the key's tombstone-resolved values — walk from `0` until `ENOENT`. 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_

> Enumerating subkeys, reading key metadata, deleting and hiding keys, and arming a watch for changes.

### 7.5.0.1 Enumerating subkeys

```c
struct peios_reg_subkey {
    void    *name;             /* in:  buffer for the child's name (NULL to probe) */
    uint64_t last_write_time;  /* out: ns since the Unix epoch */
    uint32_t name_cap;         /* in */  uint32_t name_len;    /* out */
    uint32_t subkey_count;     /* out: the child's subkey count */
    uint32_t value_count;      /* out: the child's value count */
};
int peios_reg_enum_subkey(int key_fd, uint32_t index, int txn_fd,
                          struct peios_reg_subkey *v);
```

`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

```c
struct peios_reg_key_info {
    void    *name;                 /* in:  buffer for the key's leaf name (NULL to probe) */
    uint64_t last_write_time;      /* out */
    uint64_t hive_generation;      /* out: per-hive change epoch */
    uint32_t name_cap;  uint32_t name_len;
    uint32_t subkey_count;         /* out */
    uint32_t value_count;          /* out */
    uint32_t max_subkey_name_len;  /* out */
    uint32_t max_value_name_len;   /* out */
    uint32_t max_value_data_size;  /* out */
    uint32_t sd_size;              /* out: security-descriptor size */
    uint8_t  volatile_key;         /* out: 1 if volatile */
    uint8_t  symlink;              /* out: 1 if a symlink */
};
int peios_reg_query_key_info(int key_fd, struct peios_reg_key_info *v);
```

`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

```c
int peios_reg_delete_key(int key_fd, const void *layer, uint32_t layer_len, int txn_fd);
int peios_reg_hide_key(int key_fd, const void *layer, uint32_t layer_len, int txn_fd);
```

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_key`** removes this key's path entry *in a layer*; lower-layer entries re-emerge. It fails with `ENOTEMPTY` if the key has visible children.
- **`peios_reg_hide_key`** creates a `HIDDEN` path 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

```c
int peios_reg_notify(int key_fd, uint32_t filter, int subtree);
int peios_reg_flush(int key_fd);
```

- **`peios_reg_notify`** arms change watches on `key_fd` (needs `KEY_NOTIFY`). `filter` is a mask of `REG_NOTIFY_VALUE` / `REG_NOTIFY_SUBKEY` / `REG_NOTIFY_SD` (or `REG_NOTIFY_ALL`); `subtree` (`0`/`1`) extends the watch to descendants. `filter == 0` disarms. Once armed, **the key fd itself becomes pollable** — `EPOLLIN` signals pending events, and `read()` on the fd returns the change records. So a watch integrates directly into an `epoll` loop with no side channel. Errors: `ENOENT` (orphaned key), `EINVAL`, `EACCES`.
- **`peios_reg_flush`** forces the source to persist this key's hive's pending writes (needs `KEY_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_

> Getting and setting a registry key's security descriptor, with the same secinfo mask the file API uses.

```c
int peios_reg_get_security(int key_fd, uint32_t security_info, void *sd, uint32_t cap,
                           uint32_t *sd_len_out);
int peios_reg_set_security(int key_fd, uint32_t security_info, const void *sd,
                           uint32_t sd_len, int txn_fd);
```

Keys are KACS-secured, so their SDs are read and written with the same [`<peios/security.h>`](/peios/developing-for-peios/sdk-reference/sdk-security/security-h-security-descriptors.md) vocabulary as files and tokens; `security_info` selects components (owner/group/DACL/SACL).

- **`peios_reg_get_security`** reads the selected components into `sd` (KACS binary form), writing the length to `*sd_len_out` (may be `NULL`); a too-small buffer returns `ERANGE` with the required size there, and a zero `cap` probes. Owner/group/DACL need `READ_CONTROL`; the SACL needs `ACCESS_SYSTEM_SECURITY`.
- **`peios_reg_set_security`** applies the selected components of `sd`, merging with the rest (the kernel parses and validates). The DACL needs `WRITE_DAC`, the owner `WRITE_OWNER`, the SACL `ACCESS_SYSTEM_SECURITY`. Here `txn_fd` gives **atomicity, not layer qualification** (SDs are not layered), or `-1` to 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_

> Streaming a key and everything beneath it out to a descriptor, and restoring it back.

```c
int peios_reg_backup(int key_fd, int output_fd);
int peios_reg_restore(int key_fd, int input_fd);
```

- **`peios_reg_backup`** exports the key and its entire subtree to `output_fd` (needs `SeBackupPrivilege`). 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_restore`** replaces the key and its entire subtree from `input_fd` (needs `SeRestorePrivilege`), 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_

> Batching key creates and mutating operations into an atomic unit — beginning, committing and aborting one.

A transaction batches key creates and mutating value/key operations into an atomic unit.

```c
int peios_reg_begin_transaction(void);
int peios_reg_commit(int txn_fd);
int peios_reg_txn_status(int txn_fd, uint32_t *state_out, int *terminal_errno_out);
```

- **`peios_reg_begin_transaction`** starts one and returns a transaction fd (initially unbound; it binds to a source on first use), or `-1`/`ENOMEM`. Pass this fd as the `txn_fd` argument 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_commit`** atomically 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_status`** reads a transaction's state: `state_out` receives the `REG_TXN_*` state, and `terminal_errno_out` receives the errno that ended it (`0` while active or after a clean commit). Both may be `NULL`.

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_

> Emitting events into KMES and consuming them from the ring buffers, with MessagePack payloads built by the codec module.

`<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>`](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md).

Two privileges gate the module: **emitting requires `SeAuditPrivilege`**, and **consuming (attaching to a ring) requires `SeSecurityPrivilege`**.

## 8.1.1 See also

- **[`<peios/msgpack.h>`](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md)** — building and parsing the payloads events carry.
- **[Auditing](/peios/security-fundamentals/auditing/overview.md)** — the operator-side view of the event and audit stream.

---

# 8.2 Emitting events

_Peios / Developing for Peios / SDK Reference / event.h — Events_

> Emitting a single event or a batch — the type string, the payload, and what the call validates.

```c
int peios_event_emit(const char *event_type, uint16_t event_type_len,
                     const void *payload, uint32_t payload_len);
```

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`](/peios/developing-for-peios/sdk-reference/sdk-msgpack/msgpack-h-messagepack-codec.md#validator), you can validate in userspace first and turn a would-be `EINVAL` into a check you control.

```c
/* Build a payload, then emit. */
peios_mp_writer *w = peios_mp_writer_new();
peios_mp_write_map(w, 1);
peios_mp_write_str(w, "user", 4); peios_mp_write_str(w, "alice", 5);

const void *buf; ssize_t n = peios_mp_writer_bytes(w, &buf);
if (n >= 0)
    peios_event_emit("my.app.login", 12, buf, (uint32_t)n);
peios_mp_writer_free(w);
```

### 8.2.0.1 Batch emit

```c
struct peios_event_entry {
    const char *event_type;      /* length-counted UTF-8; not NUL-terminated */
    uint16_t    event_type_len;
    const void *payload;         /* MessagePack bytes */
    uint32_t    payload_len;
};

int peios_event_emit_batch(const struct peios_event_entry *entries,
                           uint32_t count, uint32_t *emitted_out);
```

`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_

> Attaching to a ring and reading events — the high-level reader, the low-level ring, and what a consumed event points into.

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.

```c
struct peios_event {
    uint64_t timestamp;                  /* ns since the Unix epoch (CLOCK_REALTIME) */
    uint64_t sequence;                   /* per-CPU, per-boot monotonic (gap = lost events) */
    uint16_t cpu_id;
    uint8_t  origin_class;               /* 0 = userspace, 1 = KMES, 2 = KACS, 3 = LCS */
    uint8_t  effective_token_guid[16];
    uint8_t  true_token_guid[16];
    uint8_t  process_guid[16];
    const char *event_type;              /* not NUL-terminated; use event_type_len */
    uint16_t    event_type_len;
    const void *payload;                 /* a MessagePack value */
    uint32_t    payload_len;
};
```

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_type` and `payload` point 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

```c
int peios_event_attach(uint32_t cpu_id, uint64_t *capacity_out);
```

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`](#low-level-ring). 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

```c
typedef struct peios_event_reader peios_event_reader;

peios_event_reader *peios_event_reader_open(uint32_t cpu_id);
void                peios_event_reader_close(peios_event_reader *r);
int      peios_event_reader_next(peios_event_reader *r, struct peios_event *out);
int      peios_event_reader_wait(peios_event_reader *r, int timeout_ms);
uint64_t peios_event_reader_lost(const peios_event_reader *r);
```

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_open` attaches to `cpu_id` and maps its ring, ready to drain (`NULL` with `errno` on failure). `peios_event_reader_close` tears it down.
- `peios_event_reader_next` fetches the next event into `out` (non-`NULL`). Returns **`1`** (event filled), **`0`** (none available right now — consider `wait`), or **`-1`** with `errno`. The `out` pointers are valid only until the next call.
- `peios_event_reader_wait` blocks until events are available or `timeout_ms` elapses (**negative = forever**). Returns `1` (call `next`), `0` (timeout/interrupted), or `-1`.
- `peios_event_reader_lost` returns 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:

```c
peios_event_reader *r = peios_event_reader_open(cpu);
for (;;) {
    struct peios_event ev;
    int rc = peios_event_reader_next(r, &ev);
    if (rc == 1) {
        /* handle ev — copy out event_type/payload before the next call */
    } else if (rc == 0) {
        peios_event_reader_wait(r, -1);   /* sleep until more arrive */
    } else {
        break;                            /* error */
    }
}
peios_event_reader_close(r);
```

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.

```c
struct peios_event_ring { uint64_t _opaque[4]; };   /* opaque */

int  peios_event_ring_map(int fd, uint64_t capacity, struct peios_event_ring *ring);
void peios_event_ring_unmap(struct peios_event_ring *ring);

uint64_t peios_event_ring_capacity(const struct peios_event_ring *ring);
uint64_t peios_event_ring_write_pos(const struct peios_event_ring *ring);  /* acquire */
uint64_t peios_event_ring_tail_pos(const struct peios_event_ring *ring);   /* acquire */
uint64_t peios_event_ring_generation(const struct peios_event_ring *ring);
void     peios_event_ring_set_need_wake(const struct peios_event_ring *ring, int set);

ssize_t peios_event_ring_event_at(const struct peios_event_ring *ring,
                                  uint64_t read_pos, struct peios_event *out);
int     peios_event_ring_wait(const struct peios_event_ring *ring,
                              uint64_t read_pos, int timeout_ms);
```

- `peios_event_ring_map` maps and validates a ring fd from `peios_event_attach`; `ring` must be zeroed or previously unmapped (remapping an active ring fails `EBUSY`). `peios_event_ring_unmap` releases it.
- **Positions are free-running byte counters.** `write_pos` is where the producer will write next (acquire-loaded); `tail_pos` is the oldest still-live byte (advances as the ring laps); an event lives at `(read_pos & (capacity - 1))`. You drain by walking `read_pos` from `tail_pos` toward `write_pos`. `generation` changes when the buffer is resized — re-read `capacity` when it does.
- `peios_event_ring_event_at` parses the event at `read_pos` into `out` and returns its **byte size** (advance `read_pos` by that), or `-1` if the slot is corrupt. You must have confirmed `read_pos` is in `[tail_pos, write_pos)` first. Pass `out == NULL` to validate a slot and get its size without borrowing the `event_type`/`payload` pointers.
- Before sleeping, arm the advisory wake flag with `peios_event_ring_set_need_wake(ring, 1)`, then `peios_event_ring_wait` futex-waits until events past `read_pos` may be available or `timeout_ms` elapses (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:

```c
uint64_t rp = peios_event_ring_tail_pos(&ring);
for (;;) {
    uint64_t wp = peios_event_ring_write_pos(&ring);
    while (rp < wp) {
        struct peios_event ev;
        ssize_t sz = peios_event_ring_event_at(&ring, rp, &ev);
        if (sz < 0) { /* corrupt slot — resync from tail_pos */ break; }
        /* handle ev */
        rp += (uint64_t)sz;
    }
    peios_event_ring_set_need_wake(&ring, 1);
    peios_event_ring_wait(&ring, rp, -1);
}
```

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_

> A general MessagePack codec whose reason for being is event payloads — its writer, reader and validator.

`<peios/msgpack.h>` is a small, self-contained [MessagePack](https://msgpack.org) 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`](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md#emitting-events).

You can use it as a general MessagePack codec, but its reason for being is [events](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md).

It has three parts: a heap-backed **writer**, a stack-allocatable **reader**, and a **validator**.

## 9.1.1 See also

- **[`<peios/event.h>`](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md)** — the KMES events these payloads travel in.
- **[Library conventions](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md)** — the sticky-error builder model the writer follows.

---

# 9.2 Conventions

_Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding_

> The few rules that hold across the whole codec, and what they mean for buffers you pass in.

A few rules hold across the codec:

- **Integers are written in their smallest MessagePack form** automatically — you write an `int64`/`uint64` and the encoder picks the compact encoding.
- **`str` values must be valid UTF-8.** Use `bin` for arbitrary bytes. The reader enforces this on `str` reads 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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md#memory-ownership): the write calls cannot fail individually; the first error latches and surfaces at `peios_mp_writer_bytes` / `peios_mp_writer_error`.

---

# 9.3 Writer

_Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding_

> Building a MessagePack value — scalars, containers, extensions and raw bytes, and taking the finished bytes.

```c
typedef struct peios_mp_writer peios_mp_writer;

peios_mp_writer *peios_mp_writer_new(void);
void             peios_mp_writer_free(peios_mp_writer *w);
void             peios_mp_writer_reset(peios_mp_writer *w);
```

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

```c
void peios_mp_write_nil(peios_mp_writer *w);
void peios_mp_write_bool(peios_mp_writer *w, bool v);
void peios_mp_write_int(peios_mp_writer *w, int64_t v);
void peios_mp_write_uint(peios_mp_writer *w, uint64_t v);
void peios_mp_write_float(peios_mp_writer *w, double v);
void peios_mp_write_str(peios_mp_writer *w, const char *s, size_t len);  /* UTF-8 */
void peios_mp_write_bin(peios_mp_writer *w, const void *b, size_t len);
```

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

```c
void peios_mp_write_array(peios_mp_writer *w, uint32_t count);
void peios_mp_write_map(peios_mp_writer *w, uint32_t count);
```

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.

```c
/* {"user": "alice", "ok": true} */
peios_mp_write_map(w, 2);
peios_mp_write_str(w, "user", 4);  peios_mp_write_str(w, "alice", 5);
peios_mp_write_str(w, "ok", 2);    peios_mp_write_bool(w, true);
```

### 9.3.0.3 Extensions and raw bytes

```c
void peios_mp_write_ext(peios_mp_writer *w, int8_t ext_type, const void *b, size_t len);
void peios_mp_write_raw(peios_mp_writer *w, const void *b, size_t len);
```

- `peios_mp_write_ext` writes a MessagePack extension value with a signed type id.
- `peios_mp_write_raw` appends **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 at `peios_mp_writer_bytes`, so you can't smuggle malformed bytes through it.

### 9.3.0.4 Taking the bytes

```c
ssize_t peios_mp_writer_bytes(peios_mp_writer *w, const void **out);
int     peios_mp_writer_error(const peios_mp_writer *w);
```

`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_

> A stack-allocatable cursor over a borrowed buffer — peeking at the next value, then reading scalars, strings and containers.

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.

```c
struct peios_mp_reader { uint64_t _opaque[4]; };   /* opaque — do not inspect */

void   peios_mp_reader_init(struct peios_mp_reader *r, const void *buf, size_t len);
size_t peios_mp_reader_remaining(const struct peios_mp_reader *r);
```

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

```c
enum peios_mp_type {
    PEIOS_MP_NIL, PEIOS_MP_BOOL, PEIOS_MP_INT, PEIOS_MP_FLOAT,
    PEIOS_MP_STR, PEIOS_MP_BIN, PEIOS_MP_ARRAY, PEIOS_MP_MAP, PEIOS_MP_EXT,
};

int peios_mp_peek(const struct peios_mp_reader *r);
```

`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

```c
int peios_mp_read_nil(struct peios_mp_reader *r);
int peios_mp_read_bool(struct peios_mp_reader *r, bool *out);
int peios_mp_read_int(struct peios_mp_reader *r, int64_t *out);
int peios_mp_read_uint(struct peios_mp_reader *r, uint64_t *out);
int peios_mp_read_float(struct peios_mp_reader *r, double *out);
```

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

```c
ssize_t peios_mp_read_str(struct peios_mp_reader *r, const char **out);
ssize_t peios_mp_read_bin(struct peios_mp_reader *r, const void **out);
ssize_t peios_mp_read_array(struct peios_mp_reader *r);
ssize_t peios_mp_read_map(struct peios_mp_reader *r);
ssize_t peios_mp_read_ext(struct peios_mp_reader *r, int8_t *type_out, const void **out);
int     peios_mp_skip(struct peios_mp_reader *r);
```

- `peios_mp_read_str` / `peios_mp_read_bin` **borrow** the bytes (a pointer into the reader's buffer via `out`) and return the length, or `-1`. Strings are **not** NUL-terminated — use the length — and `peios_mp_read_str` rejects invalid UTF-8.
- `peios_mp_read_array` returns the **element count**; `peios_mp_read_map` returns the **key/value pair count** (so read `2 * count` values). After the header you read that many values yourself.
- `peios_mp_read_ext` borrows an extension value's bytes, reporting its signed type id through `type_out` (both `type_out` and `out` are independently optional), and returns the data length.
- `peios_mp_skip` consumes **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`.

```c
struct peios_mp_reader r;
peios_mp_reader_init(&r, payload, payload_len);

ssize_t pairs = peios_mp_read_map(&r);          /* top-level map */
for (ssize_t i = 0; i < pairs; i++) {
    const char *key; ssize_t klen = peios_mp_read_str(&r, &key);
    /* dispatch on key… then read or skip the value */
    peios_mp_skip(&r);
}
```

---

# 9.5 Validator

_Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding_

> Checking a buffer in one pass, with acceptance that matches the kernel's own emit-time check.

```c
int peios_mp_validate(const void *buf, size_t len, uint32_t max_depth);
```

`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](/peios/developing-for-peios/sdk-reference/sdk-events-api/event-h-events-kmes.md#emitting-events) 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_

> The one job of this header — declaring which hives your process backs, registering with the kernel, and getting a source fd.

`<rsi/source.h>` is where a registry **source** begins. A source is a storage backend for the [LCS registry](/peios/developing-for-peios/sdk-registry/overview.md) — 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](/peios/developing-for-peios/sdk-reference/sdk-rsi-request/rsi-request-h-decoding-requests.md) and [writing responses](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md). 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](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md): raw fds, `int` returning `0`/`-1`+errno, and the errno passed straight through from the kernel.

## 10.1.1 See also

- **[Registry sources overview](/peios/developing-for-peios/registry-sources/overview.md)** — what a source is and how the RSI protocol flows.
- **[The registry](/peios/using-peios/registry-concepts/overview.md)** — 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_

> The rsi_hive struct a source fills in per hive it backs, field by field.

A **hive** is a subtree of the registry with its own root key. A source declares one `struct rsi_hive` per hive it backs:

```c
struct rsi_hive {
    const void *name;         /* hive name (not NUL-terminated) */
    uint32_t    name_len;
    uint32_t    flags;        /* RSI_HIVE_PRIVATE, or 0 for a global hive */
    uint8_t     root_guid[16];/* root key GUID */
    uint8_t     scope_guid[16];/* private hives; zero for a global hive */
};
```

| 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](/peios/developing-for-peios/sdk-reference/sdk-tokens/token-h-tokens-and-sessions.md#lcs-registry-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_

> rsi_register opens the registry device and registers every declared hive, returning the source fd the serve loop runs on.

```c
int rsi_register(const struct rsi_hive *hives, uint32_t count, uint64_t max_sequence);
```

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)`.

```c
struct rsi_hive hive = {
    .name = "MyStore", .name_len = 7,
    .flags = 0,                                  /* global hive */
    .root_guid = { /* … 16 bytes … */ },
};

int src = rsi_register(&hive, 1, /*max_sequence=*/0);
if (src < 0) { perror("rsi_register"); return -1; }
/* `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 — where the serve loop and the response helpers live.

Registration is the whole of this header. Once you hold the source fd, the serve loop lives in the other two:

- **[`<rsi/request.h>`](/peios/developing-for-peios/sdk-reference/sdk-rsi-request/rsi-request-h-decoding-requests.md)** — read and decode the requests the kernel sends.
- **[`<rsi/response.h>`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md)** — build and send the replies.

The [serving requests](/peios/developing-for-peios/registry-sources/serving-requests.md) 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_

> The shape of every serve loop — read a frame, parse the header, dispatch on the op-code, decode with the matching parser.

`<rsi/request.h>` is the receiving half of a registry source's serve loop. The kernel sends your source [RSI](/peios/developing-for-peios/registry-sources/overview.md) 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 next `rsi_read_request`. Copy out anything you need to keep across iterations. This is the same [borrow discipline](/peios/developing-for-peios/sdk-reference/sdk-conventions/library-conventions.md#memory-ownership) 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>`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md)** — building the reply each op expects.
- **[Serving requests](/peios/developing-for-peios/registry-sources/serving-requests.md)** — 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_

> The rsi_request struct, what each field means, and how to read one frame off the source fd and parse its header.

```c
struct rsi_request {
    uint64_t    request_id;   /* echo this in the response */
    uint64_t    txn_id;       /* transaction id (0 outside a transaction) */
    const void *payload;      /* borrowed; valid until the frame is reused */
    uint32_t    payload_len;
    uint16_t    op_code;      /* RSI_LOOKUP, RSI_SET_VALUE, … — dispatch on this */
};

ssize_t rsi_read_request(int fd, void *buf, size_t cap);
int     rsi_parse_request(const void *frame, size_t len, struct rsi_request *out);
```

- **`rsi_read_request`** reads one framed request from the source fd into `buf` — a thin `read(2)` wrapper that **blocks** until a request is queued, then returns the frame length (pass it to `rsi_parse_request`). It returns **`0` at EOF** (the source is closing — leave the loop) or `-1` with `errno`, notably **`EMSGSIZE`** if `cap` is smaller than the pending frame (size `buf` generously, or grow and retry).
- **`rsi_parse_request`** splits a frame into its header and payload view, filling `out` with the `request_id` (which you must echo in the response), the `txn_id` (`0` when the request is not inside a transaction), the `op_code` to dispatch on, and a **borrowed** `payload` pointer. Returns `0`, or `-1` with `errno` (`EINVAL` on NULL args, `EBADMSG` on a malformed frame).

---

# 11.3 The decoders

_Peios / Developing for Peios / SDK Reference / rsi/request.h — Decoding Requests_

> One decoder per operation, each filling a flat struct with GUIDs by value and names as borrowed pointers.

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*.

```c
/* LOOKUP — is child_name visible under parent_guid? */
struct rsi_lookup {
    uint8_t     parent_guid[16];
    const void *child_name;  uint32_t child_name_len;
};
int rsi_request_lookup(const struct rsi_request *req, struct rsi_lookup *out);

/* CREATE_ENTRY — bind child_name → child_guid in layer_name. */
struct rsi_create_entry {
    uint8_t     parent_guid[16];
    uint8_t     child_guid[16];
    const void *child_name;  uint32_t child_name_len;
    const void *layer_name;  uint32_t layer_name_len;
    uint64_t    sequence;
};
int rsi_request_create_entry(const struct rsi_request *req, struct rsi_create_entry *out);

/* HIDE_ENTRY — tombstone child_name in layer_name. */
struct rsi_hide_entry {
    uint8_t     parent_guid[16];
    const void *child_name;  uint32_t child_name_len;
    const void *layer_name;  uint32_t layer_name_len;
    uint64_t    sequence;
};
int rsi_request_hide_entry(const struct rsi_request *req, struct rsi_hide_entry *out);

/* DELETE_ENTRY — remove child_name's entry in layer_name. */
struct rsi_delete_entry {
    uint8_t     parent_guid[16];
    const void *child_name;  uint32_t child_name_len;
    const void *layer_name;  uint32_t layer_name_len;
};
int rsi_request_delete_entry(const struct rsi_request *req, struct rsi_delete_entry *out);

/* ENUM_CHILDREN — list the children of parent_guid. */
struct rsi_enum_children { uint8_t parent_guid[16]; };
int rsi_request_enum_children(const struct rsi_request *req, struct rsi_enum_children *out);
```

| Op | You must | Reply with |
|---|---|---|
| `LOOKUP` | Resolve `child_name` under `parent_guid` across your layers. | [`rsi_respond_lookup`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md#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`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md#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).

```c
/* CREATE_KEY — create the metadata record guid under parent_guid. */
struct rsi_create_key {
    uint8_t     guid[16];
    uint8_t     parent_guid[16];
    const void *name;  uint32_t name_len;
    const void *sd;    uint32_t sd_len;
    uint8_t     volatile_key;  /* 1 if volatile */
    uint8_t     symlink;       /* 1 if a symlink */
};
int rsi_request_create_key(const struct rsi_request *req, struct rsi_create_key *out);

/* READ_KEY / DROP_KEY — a request carrying just a key GUID. */
struct rsi_key_guid { uint8_t guid[16]; };
int rsi_request_read_key(const struct rsi_request *req, struct rsi_key_guid *out);
int rsi_request_drop_key(const struct rsi_request *req, struct rsi_key_guid *out);

/* WRITE_KEY — update the mutable fields of guid named by field_mask. */
struct rsi_write_key {
    uint8_t     guid[16];
    uint32_t    field_mask;        /* RSI_WRITE_KEY_FIELD_SD | …_LAST_WRITE_TIME */
    const void *sd;  uint32_t sd_len;/* NULL when the SD bit is clear */
    uint64_t    last_write_time;   /* valid only when the time bit is set */
};
int rsi_request_write_key(const struct rsi_request *req, struct rsi_write_key *out);
```

| 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`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md#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.

```c
/* QUERY_VALUES — read value_name (or all values when query_all) of guid. */
struct rsi_query_values {
    uint8_t     guid[16];
    const void *value_name;  uint32_t value_name_len;
    uint8_t     query_all;   /* 1 = every value (then value_name is ignored) */
};
int rsi_request_query_values(const struct rsi_request *req, struct rsi_query_values *out);

/* SET_VALUE — store value_name in layer_name with the given type/data. */
struct rsi_set_value {
    uint8_t     guid[16];
    const void *value_name;  uint32_t value_name_len;
    const void *layer_name;  uint32_t layer_name_len;
    uint32_t    value_type;
    const void *data;  uint32_t data_len;
    uint64_t    sequence;
    uint64_t    expected_sequence;  /* CAS guard (0 disables) */
};
int rsi_request_set_value(const struct rsi_request *req, struct rsi_set_value *out);

/* DELETE_VALUE_ENTRY — remove value_name's entry in layer_name. */
struct rsi_delete_value_entry {
    uint8_t     guid[16];
    const void *value_name;  uint32_t value_name_len;
    const void *layer_name;  uint32_t layer_name_len;
};
int rsi_request_delete_value_entry(const struct rsi_request *req,
                                   struct rsi_delete_value_entry *out);

/* SET_BLANKET_TOMBSTONE — set or clear a blanket tombstone on layer_name. */
struct rsi_set_blanket_tombstone {
    uint8_t     guid[16];
    const void *layer_name;  uint32_t layer_name_len;
    uint8_t     set;         /* 1 = set, 0 = clear */
    uint64_t    sequence;
};
int rsi_request_set_blanket_tombstone(const struct rsi_request *req,
                                      struct rsi_set_blanket_tombstone *out);
```

| 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`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md#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.

```c
/* BEGIN_TRANSACTION — open transaction_id in mode. */
struct rsi_begin_transaction {
    uint64_t    transaction_id;
    uint32_t    mode;   /* RSI_TXN_READ_WRITE (0) or RSI_TXN_READ_ONLY (1) */
};
int rsi_request_begin_transaction(const struct rsi_request *req,
                                  struct rsi_begin_transaction *out);

/* COMMIT_TRANSACTION / ABORT_TRANSACTION — a request carrying just a transaction id. */
struct rsi_transaction { uint64_t transaction_id; };
int rsi_request_commit_transaction(const struct rsi_request *req, struct rsi_transaction *out);
int rsi_request_abort_transaction(const struct rsi_request *req, struct rsi_transaction *out);
```

| 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

```c
/* DELETE_LAYER / FLUSH — a request carrying just a length-prefixed name. */
struct rsi_name { const void *name;  uint32_t name_len; };
int rsi_request_delete_layer(const struct rsi_request *req, struct rsi_name *out);
int rsi_request_flush(const struct rsi_request *req, struct rsi_name *out);
```

| Op | You must | Reply with |
|---|---|---|
| `DELETE_LAYER` | Remove the entire named layer, reporting the GUIDs of any keys it orphaned. | [`rsi_respond_delete_layer`](/peios/developing-for-peios/sdk-reference/sdk-rsi-response/rsi-response-h-building-responses.md#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_

> Echoing the request id and op-code with an RSI status — the response helpers, and why most operations are status-only.

`<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>`](/peios/developing-for-peios/sdk-reference/sdk-rsi-request/rsi-request-h-decoding-requests.md)** — decoding the request each of these replies to.
- **[Building responses](/peios/developing-for-peios/registry-sources/building-responses.md)** — choosing and filling the right responder.

---

# 12.2 Status codes

_Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses_

> The statuses a response may carry and the errno each becomes for the registry client, so the right one matters.

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_

> The rules every response helper enforces, and what violating one costs you.

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 be `NULL` only when its length/count is zero.
- **Boolean fields** (`volatile_key`, `symlink`, target types) must be exactly `0` or `1`.
- **Hidden path targets** (`RSI_PATH_TARGET_HIDDEN`) must carry an **all-zero** `target_guid`.
- **`LOOKUP`/`ENUM_CHILDREN` metadata** must exactly cover the GUID path targets referenced — no missing metadata, no duplicates, no unreferenced entries.
- **`DELETE_LAYER` orphan 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_

> Writing an already-built response frame to the source fd, for a source that encodes its own.

```c
ssize_t rsi_write_response(int fd, const void *frame, size_t len);
```

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_

> The workhorse helper — what it is for, and the cases where it is the whole of a response.

```c
int rsi_respond_status(int fd, const struct rsi_request *req, uint32_t status);
```

The workhorse. Use it for:

- **status-only ops on success** — pass `status = RSI_OK`; and
- **any op reporting a non-OK status** — a `LOOKUP` that found nothing, a `SET_VALUE` that failed a compare-and-swap, a permission error: reply with the appropriate `RSI_*` 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_

> The five operations that return data on success, and the flat arrays each helper takes to encode the frame for you.

Five operations return data on success. Each takes the result as flat arrays and encodes the frame for you.

### 12.6.0.1 LOOKUP

```c
struct rsi_path_entry {
    const void *layer;  uint32_t layer_len;
    uint8_t     target_type;   /* RSI_PATH_TARGET_GUID (0) / RSI_PATH_TARGET_HIDDEN (1) */
    uint8_t     target_guid[16];
    uint64_t    sequence;
};
struct rsi_key_metadata {
    uint8_t     guid[16];
    const void *sd;  uint32_t sd_len;
    uint8_t     volatile_key;
    uint8_t     symlink;
    uint64_t    last_write_time;
};

int rsi_respond_lookup(int fd, const struct rsi_request *req,
                       const struct rsi_path_entry *entries, uint32_t entry_count,
                       const struct rsi_key_metadata *metadata, uint32_t metadata_count);
```

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

```c
struct rsi_child_entry {
    const void            *child_name;  uint32_t child_name_len;
    const struct rsi_path_entry *entries;  uint32_t entry_count;
};

int rsi_respond_enum_children(int fd, const struct rsi_request *req,
                              const struct rsi_child_entry *children, uint32_t child_count,
                              const struct rsi_key_metadata *metadata, uint32_t metadata_count);
```

Answers an `ENUM_CHILDREN` with each `child` — its name and the [path entries](#lookup) 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

```c
int rsi_respond_read_key(int fd, const struct rsi_request *req, const void *name,
                         uint32_t name_len, const uint8_t *parent_guid, const void *sd,
                         uint32_t sd_len, uint8_t volatile_key, uint8_t symlink,
                         uint64_t last_write_time);
```

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

```c
struct rsi_value_entry {
    const void *value_name;  uint32_t value_name_len;
    const void *layer_name;  uint32_t layer_name_len;
    uint32_t    value_type;
    const void *data;  uint32_t data_len;
    uint64_t    sequence;
};
struct rsi_blanket_entry {
    const void *layer_name;  uint32_t layer_name_len;
    uint64_t    sequence;
};

int rsi_respond_query_values(int fd, const struct rsi_request *req,
                             const struct rsi_value_entry *entries, uint32_t entry_count,
                             const struct rsi_blanket_entry *blankets, uint32_t blanket_count);
```

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

```c
int rsi_respond_delete_layer(int fd, const struct rsi_request *req,
                             const uint8_t *orphaned_guids, uint32_t orphaned_count);
```

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_

> A one-page summary of the payload-bearing responses, and the rule covering everything else.

| 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 — development paths built into core Peios software. Its first component, dwed, is an unauthenticated SYSTEM control surface that lets you drive a running machine from outside, across as long as an investigation takes.

**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.

> [!CAUTION]
> `dwed` performs **no authentication of any kind**. Anything that can reach its socket owns that machine as `SYSTEM`. It exists only on a dedicated development ISO and must never be present on a machine you would not hand to a stranger.
>
> This is the design, not a gap to be closed later. See [The security posture](#the-security-posture).

## 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 `stdout` and `stderr` — 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`:

- `stdout` and `stderr` come 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 `base64` improvised 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:

```text
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](/peios/developing-for-peios/dwe/protocol.md), 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.

> [!NOTE]
> `dwed` is a phase-2 service, so a boot that breaks before phase 2 has no DWE at all. It also goes down with a machine that wedges completely. Both are accepted limits: below that line, a serial console is still the tool.

## Next

- [Driving a machine](/peios/developing-for-peios/dwe/driving-a-machine.md) — booting with a vsock device, and the `dwe` command.
- [The DWE protocol](/peios/developing-for-peios/dwe/protocol.md) — the wire format, for building against it directly.

---

# Driving a machine

_Peios / Developing for Peios / DWE_

> Booting a Peios machine with a vsock device, pointing the dwe client at it, and running commands, moving files and detaching long work that outlives the connection.

This page assumes an image built with the `peios-dwe` package and its service seed applied. If you are not sure, [What DWE is](/peios/developing-for-peios/dwe/what-dwe-is.md) 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:

```sh
-device vhost-vsock-pci,guest-cid=3
```

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:

```sh
cd dist/prod
make boot-dwe              # or: make boot-dwe DWE_CID=4
```

Leave it running. Unlike a scripted boot, the point is that the machine stays up.

> [!NOTE]
> `/dev/vhost-vsock` is owned by `root:kvm`, so membership of the `kvm` group is enough — this does not need root.
>
> On a guest booted *without* a vsock device, `dwed` reports that it has no transport and exits cleanly. That is not an error and does not restart-loop.

## Point the client at it

The `dwe` client takes its target from `--target` or from `DWE_TARGET`:

```sh
export DWE_TARGET=vsock:3:4820        # a VM by context id
export DWE_TARGET=tcp:10.0.0.5:4820   # or over the network
```

The port defaults to 4820 and can be left off. Confirm you have the machine you think you have:

```console
$ 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

```console
$ 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:

```sh
dwe exec -- sh -c 'for p in /proc/[0-9]*; do cat $p/comm; done'
```

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:

```sh
dwe exec -- test -f /etc/hostname && echo "it is there"
```

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:

```console
$ dwe exec -- ls /nonexistent 2>errors.txt
$ cat errors.txt
ls: cannot access '/nonexistent': No such file or directory
```

## Move files

```sh
dwe pull /var/log/peinit.log ./peinit.log   # out of the guest
dwe pull /etc/hostname                      # or straight to stdout
dwe push ./probe.sh /tmp/probe.sh           # into the guest
echo "hello" | dwe push - /tmp/greeting     # from stdin
```

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:

```console
$ 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:

```console
$ 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:

```console
$ 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.

> [!IMPORTANT]
> Job state lives in memory. A restart of `dwed` restores the listener but loses every job and everything it had captured. Output is capped at 16 MiB per stream, after which the tail is dropped and the reply says so — pipe a genuinely large job to a file in the guest and `pull` it instead.

## 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_

> The dwed wire protocol — newline-delimited JSON over vsock or TCP, the request and reply shapes for every operation, error codes, and the transport and versioning rules.

`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:

```console
$ 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:

```json
{"id": 7, "op": "exec", "argv": ["ls", "-l", "/system"]}
```

`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`:

```json
{"id": 7, "ok": {"reply": "exec", "exit": 0, "stdout": "…", "stderr": "", "truncated": false}}
{"id": 7, "error": {"code": "no_such_job", "message": "job 9 is unknown"}}
```

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.

> [!CAUTION]
> Neither transport authenticates anything. Whatever reaches the socket owns the machine as `SYSTEM`. See [the security posture](/peios/developing-for-peios/dwe/what-dwe-is.md#the-security-posture).

## 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: it turns a declarative peiso.toml spec into a bootable image tree, layering boot machinery over a peipkg-compose root.

**`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`](/peios/using-peios/package-management/composing-a-root.md)'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](/peios/using-peios/package-management/composing-a-root.md) 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.

```mermaid
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
```

1. **Package root.** peiso calls `peipkg-compose build` on 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.)

2. **Initramfs cpio.** peiso chroots into the composed root and runs the root's own **[`mkirf`](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md)** applet 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 file `inject` list — a temporary bypass of packaging, used for things like the live-boot hook until they are properly packaged.)

3. **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](/peios/security-fundamentals/security-descriptors/overview.md) 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.

4. **UKI (optional).** peiso chroots in again and runs the root's **[`mkuki`](/peios/security-fundamentals/boot-and-trust-establishment/mkuki.md)** applet 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.

5. **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.squashfs` in the reference spec) — written beside `root/`, 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](/peios/peiso/building-images/running-a-build.md).

See [Running a build](/peios/peiso/building-images/running-a-build.md) for the command in detail, and [The build spec](/peios/peiso/reference/the-build-spec.md) 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 = 1` spec 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-`inject` packaging 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](/peios/peiso/building-images/the-build-pipeline.md).
- To run a build — the command, root requirement, and how peiso finds `peipkg-compose` — read [Running a build](/peios/peiso/building-images/running-a-build.md).
- For every field of `peiso.toml`, section by section, read [The build spec](/peios/peiso/reference/the-build-spec.md).
- For the package stage beneath peiso — how the root is resolved, verified, and laid out — read [Composing a root](/peios/using-peios/package-management/composing-a-root.md).

---

# Quick start

_Peios / Peiso / Building images_

> Run your first peiso build — a minimal peiso.toml that composes the package root and packs the initramfs, and what it leaves on disk.

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](/peios/peiso/building-images/the-build-pipeline.md) 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-compose` on `PATH`.** peiso shells out to it to compose the root. If it is installed somewhere `sudo` cannot see, set `PEISO_COMPOSE` to its path — the full lookup chain is in [Running a build](/peios/peiso/building-images/running-a-build.md#environment).
- **A package source.** peiso does not fetch or build packages. You need built `.peipkg` files (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 include `peiosutils` (which ships the `mkirf` applet the build runs inside the root) and `fsbase` (which owns the `/system/boot` directory the cpio is written into). The Peios repository's `dist/prod/manifest.toml` is the reference manifest, composed from the repository's own farm; writing manifests is covered in [Composing a root](/peios/using-peios/package-management/composing-a-root.md).

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_packages` globs;
- `peiso.toml` — written in the next step.

## Write the minimal spec

Create `peiso.toml` next to the manifest:

```toml
schema = 1

[root]
manifest = "manifest.toml"
out      = "root"

[initramfs]
dir  = "boot/initramfs"
cpio = "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](/peios/peiso/reference/the-build-spec.md).

## Run the build

```bash
cd /path/to/your/build/dir
sudo peiso 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 forgot `sudo`.
- `peipkg-compose not found on PATH or next to peiso (set PEISO_COMPOSE to override)` — see the [environment section of Running a build](/peios/peiso/building-images/running-a-build.md#environment); `sudo`'s `secure_path` is the usual culprit.
- `/usr/bin/mkirf not in the composed root … (is peiosutils composed in?)` — your manifest did not compose `peiosutils` in, so the root has no `mkirf` to 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:

```bash
ls -lh root/system/boot/
```

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](/peios/peiso/building-images/the-build-pipeline.md) walks every stage in order — the compose, the chroot, and the optional stages this build skipped.
- **The command in full.** [Running a build](/peios/peiso/building-images/running-a-build.md) 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](/peios/peiso/reference/the-build-spec.md).

---

# The build pipeline

_Peios / Peiso / Building images_

> Each stage of a peiso build in order, from compose to ISO — which stages run host tools, which run Peios applets in the chroot, and why the order matters.

A single `sudo peiso build <spec>` turns one declarative [build spec](/peios/peiso/reference/the-build-spec.md) 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`](/peios/using-peios/package-management/composing-a-root.md)'s job, and peiso shells out to it. What peiso adds is everything compose [deliberately does not do](/peios/using-peios/package-management/composing-a-root.md): 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 `chroot`s 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 `chroot`s — it checks this first, and the failure message and full rationale are covered in [Running a build](/peios/peiso/building-images/running-a-build.md).

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

> [!WARNING]
> peiso recursively deletes the prior `[root].out` directory, as root. This is necessary because `peipkg-compose` **refuses to write into an existing directory** — its build is atomic, so the output is either absent or a complete root. peiso owns `root/` as a rebuildable artifact, so it clears it for a clean rebuild. Because a stray recursive delete running as root is dangerous, the clean is guarded: it refuses obviously unsafe targets (empty, `.`, the filesystem root, or any path whose parent is itself). This single clean also clears the nested initramfs root underneath.

### 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](/peios/using-peios/package-management/composing-a-root.md). `--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](/peios/using-peios/package-management/named-roots.md) 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](/peios/using-peios/package-management/overview.md) 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](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md) 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](/peios/using-peios/services-and-jobs/overview.md) 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`](/peios/using-peios/registry-administration/lcs-and-sources.md) is up and **before** [Phase 2](/peios/using-peios/services-and-jobs/boot-and-boot-modes.md) 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](/peios/security-fundamentals/boot-and-trust-establishment/mkuki.md). 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](/peios/peiso/reference/the-build-spec.md).

For how to invoke a build and what it prints, read [Running a build](/peios/peiso/building-images/running-a-build.md).

---

# Running a build

_Peios / Peiso / Building images_

> How to run peiso build: the one command and its spec argument, why it must run as root, its exit codes, the tools it needs, and the dist Makefile workflow.

`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](/peios/peiso/building-images/the-build-pipeline.md); for the spec it reads, see [The build spec](/peios/peiso/reference/the-build-spec.md).

## 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`](/peios/using-peios/package-management/composing-a-root.md), 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](/peios/peiso/building-images/the-build-pipeline.md); 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](#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:

1. looking it up on `PATH`; then
2. falling back to a **sibling of the peiso binary itself** — the two are installed together (`go install` drops both in `GOBIN`), and `sudo`'s `secure_path` routinely drops `GOBIN` from `PATH`, 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.toml` spec** — 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](/peios/peiso/reference/the-build-spec.md).
- **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](/peios/using-peios/package-management/composing-a-root.md).

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:

```make
PEISO := $(shell command -v peiso)

root: peiso.toml manifest.toml repo
	@test -n "$(PEISO)" || { echo "peiso not found on PATH — run 'go install' in ../../peiso" >&2; exit 1; }
	sudo $(PEISO) build peiso.toml
```

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 `dd`ing 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](/peios/peiso/building-images/the-build-pipeline.md). In brief:

- the composed **`root/`** tree — the package root with the packed initramfs cpio inside it;
- the **rootfs squashfs** (named by `[squashfs].out`) — written beside `root/`, 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](/peios/peiso/reference/the-build-spec.md) — every `peiso.toml` field the command reads.
- [The build pipeline](/peios/peiso/building-images/the-build-pipeline.md) — what each stage of the run does.
- [Composing a root](/peios/using-peios/package-management/composing-a-root.md) — the manifest the spec points at.

---

# The build spec

_Peios / Peiso / Reference_

> Field-by-field reference for peiso.toml: every table and key with types, defaults and path handling, plus the exact validation rules peiso enforces.

**`peiso.toml`** is the declarative TOML spec that drives [`peiso`](/peios/peiso/building-images/overview.md). A single file describes the whole bootable image: the [peipkg-compose manifest](/peios/using-peios/package-management/composing-a-root.md) 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](/peios/peiso/building-images/the-build-pipeline.md) 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.
- **`schema` is checked first.** The spec must declare `schema = 1`; any other value (or a missing `schema`, which reads as `0`) fails with `unsupported 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](#validation)).

## 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:

```toml
schema      = 1
source_date = "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.
bypass_path_restrictions = 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.
[root]
manifest = "manifest.toml"
out      = "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.
[initramfs]
dir     = "boot/initramfs"
cpio    = "system/boot/initramfs.cpio.gz"
exclude = ["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.
[squashfs]
out         = "rootfs.squashfs"
compression = "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.
[uki]
cmdline_file = "root/usr/share/live-boot/cmdline"
out          = "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).
[iso]
source   = "root/boot/efi"
efi_boot = "EFI/BOOT/BOOTX64.EFI"
out      = "peios.iso"
label    = "PEIOS"

# Features this image enables on first boot (feat add <name>).
[features]
enable = ["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`](/peios/using-peios/package-management/composing-a-root.md). |
| `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](/peios/using-peios/package-management/composing-a-root.md) 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](/peios/using-peios/package-management/overview.md) 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](#registry--registry-seeds-auto-applied-on-first-boot-optional), 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.

> [!CAUTION]
> An injected file is only as removable as the spec that placed it. That is an advantage for a bring-up affordance — deleting the `[[root.inject]]` entry deletes the file, with nothing to uninstall — and a hazard for anything else, because no package manifest records that it was ever there.

## `[initramfs]` — packing the initramfs (required)

Describes how peiso packs the initramfs root into a cpio archive using the composed root's own [`mkirf`](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md) 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`](/peios/security-fundamentals/boot-and-trust-establishment/mkirf.md), 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](/peios/security-fundamentals/boot-and-trust-establishment/mkuki.md) — 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.

```toml
[[iso.include]]
src  = "repo"
dest = "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.

> [!IMPORTANT]
> A booted system can only read the data area if something mounts the medium. On a live image that is [live-boot](/peios/security-fundamentals/boot-and-trust-establishment/initramfs-stage.md)'s root-mount hook, which carries the medium to `/media/peios` after the pivot — prelude moves only `/proc`, `/sys` and `/dev` into the new root and then chroots, so a mount nobody relocates is unreachable afterwards.

> [!NOTE]
> Symlinks are refused rather than followed. ISO9660 without Rock Ridge cannot represent one, and silently following it would place a copy where the image author wrote a reference.

## `[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](#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.** `schema` must equal `1`, or the build fails with `unsupported schema`.
- **Required fields.** `root.manifest`, `root.out`, `initramfs.dir` and `initramfs.cpio` must 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_boot` and `iso.out`; and both `src` and `dest` on every `inject` entry, in `[[root.inject]]` and `[[initramfs.inject]]` alike.
- **No-escape path checks.** The root-relative paths `initramfs.dir`, `initramfs.cpio`, `uki.out`, `iso.efi_boot` and every `inject.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** of `cmdline` or `cmdline_file` must be set. Supplying both, or neither, is an error.
- **Registry seeds must be bare filenames.** Each `registry.autoapply` entry 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.enable` entry 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 generated `feat add` line.
- **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](/peios/peiso/building-images/the-build-pipeline.md) runs.

## See also

- [Building a Peios image](/peios/peiso/building-images/overview.md) — what peiso is and where it sits above compose.
- [The build pipeline](/peios/peiso/building-images/the-build-pipeline.md) — the stage each table drives, in run order.
- [Running a build](/peios/peiso/building-images/running-a-build.md) — the command, exit codes, and required tools.
