# Advanced Peios

> The deeper end of Peios — internals and behaviour beyond everyday operation.

---

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