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

Peios System Protocols Userspace

The foundational userspace protocols — public, stable contracts between the components a Peios system is built from, which third parties may implement.

Single-page view · as markdown

1.1 Scope

Peios / Peios technical reference / PSPU / Introduction

This document defines the Peios System Protocols Userspace (PSPU): the protocols and interchange formats by which the foundational userspace components of a Peios system agree with one another.

An interface belongs in this document when both of these hold:

  • it is a contract between userspace parties, at least one of which is a component the system is built from rather than an application running on it; and
  • the interface is public — a third party is expected to implement one side of it.

The parties need not exist at the same moment. A live protocol has two processes in conversation; an interchange format has a producer and a consumer that never meet, and the artifact between them carries the contract. Both are in scope, because what makes something belong here is that two independently written parties must agree on it.

1.1.1 These protocols are not conformance requirements #

A system that does not offer a protocol in this document is still Peios. The components that speak these protocols are one answer to a problem, not the definition of the platform; a system that solves the same problem with different components conforms exactly as well.

They are specified because they are public even so. A third party writing a component to plug into one side of one of these protocols needs the contract written down, and needs it to stay put. What they are not is a bar anyone must clear.

For each interface, this document covers:

  • for a live protocol: the channel, its direction, which party connects to which, message framing and encoding, the messages exchanged, and the shape of a conversation
  • for an interchange format: the layout of the artifact, how it is identified and versioned, and how a consumer validates one it receives
  • how a party announces itself or is identified, and how its counterpart establishes what it is and what it may speak for
  • the rules under which the format may be extended
  • what each party must declare about itself, and what its counterpart validates rather than believes
  • the conformance requirements for each role

This document does not cover:

  • Standards a system MUST implement to be Peios — defined in PGSS
  • Protocols spoken across the kernel boundary — defined in PSPK
  • The binary structures these interfaces carry — defined in PCDS
  • How a component stores its data, reaches the answers it gives, or produces the artifacts it emits — its own design
  • Which counterparts a system is configured to trust, and how that configuration is expressed — the consuming component's own design
  • Administering a component's contents — its own design, unless the component publishes a protocol for other programs to administer it with, as the local principal store does (§10)

The fourth of those is the point of the whole document. A component is asked a question and gives an answer, or is asked for an artifact and produces one; how it arrives there is exactly what different components exist to do differently.

1.1.2 Stability #

Publication here is a commitment that the contract is written down and will not change out from under an implementation. Each specification states its own rules for extending its wire or file format; those rules are the supported way for an interface to grow.

1.2 Conventions

Peios / Peios technical reference / PSPU / Introduction

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

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

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

2.1 Scope and Roles

Peios / Peios technical reference / PSPU / Principal Source Interface

This chapter specifies the Principal Source Interface (PSI): the protocol by which an authentication authority federates identity to separate processes that hold it.

Two roles participate.

The authority is the process that mints tokens and creates logon sessions. It is the party asking. On PSI it listens; it never dials out (§2.3). It is also the party that speaks PGSS Logon to its clients, and PSI exists so that it can answer them.

The source, in full a principal source, is a process that is authoritative for some set of principals: it verifies their credentials and says who they are. The source role is publicly implementable: any process an authority has been configured to accept MAY register as a source, and a third party writing one for a directory, a hardware token service or an identity provider is the case this chapter is written for. A conforming source is the subject of the source obligations in §2.22.

A source is not a store, necessarily. A local source owns its bytes; a directory-backed source owns nothing and forwards the question. The interface deliberately does not distinguish them, which is why the term is "source" rather than "store".

What a source is emphatically not is a component of the authority. It runs as a separate process, at lower trust, and it cannot mint anything (§2.4).

This chapter covers:

  • the channel, its direction, and why sources connect inward (§2.3, §2.6)
  • message framing, the conversation identifier, and the rules under which the format may be extended (§2.7)
  • registration: how a source announces itself, how the authority establishes what it is, and what domain it may speak for (§2.8 to §2.10)
  • the relayed interrogation, and its relationship to PGSS Logon (§2.5, §2.12)
  • assertion and refusal, the terminal messages of a source conversation (§2.13)
  • querying a source outside a logon, so that an authority can serve PGSS Logon's identity lookup (§2.15, §2.16)
  • what a source must declare about itself before an authority may cache its answers (§2.8, §2.17)
  • scope: what a source may claim about identity, separately about membership, and separately again about POSIX identifiers (§2.18 to §2.20)
  • relaying a principal's change of their own credential to the source that holds it (§2.21)
  • the obligations binding on each role (§2.22)
  • relaying a principal's addition or removal of one of their own credentials, such as an SSH public key, to the source that holds it (§2.23)

This chapter does not cover:

  • The logon protocol itself, specified in PGSS.
  • Tokens, SIDs, sessions and privileges — described in the Peios Kernel TRM, with SIDs, security descriptors and claim attributes specified in PCDS.
  • How a source stores identity, or verifies a credential.
  • Derivation — what a token ends up containing — which is the authority's, applying local policy (PGSS §2.1).
  • Which sources a machine trusts, and how that is configured.
  • Which identifier range a source is given, and how that is configured. The rules the assignment must satisfy are §2.20.
  • Administering a source's contents.

The third of those is the point of the whole interface. A source is asked a question and gives an answer; how it reaches the answer is exactly what different sources exist to do differently.

2.1.1 PSI is not a conformance requirement #

PGSS Logon is a Peios Generic System Standard: a system that does not offer it is not Peios. PSI is not. It is the interface an authority uses to reach the processes that know who exists, and a system running entirely different authentication infrastructure is still Peios.

It is specified because it is a public interface even so. A third party writing a principal source needs the contract written down, and needs it to be stable. What it is not is a bar anyone must clear.

Note

The distinction shows up in what happens when you disagree with each document. Disagreeing with PGSS Logon means shipping something that is not Peios. Disagreeing with this chapter means shipping an authority that federates differently, or does not federate at all, which is a design choice nobody will dispute.

2.2 Terminology

Peios / Peios technical reference / PSPU / Principal Source Interface

Terms defined in the Peios Kernel TRM (token, logon session, privilege), in PCDS (SID, security descriptor, claim attribute) and in PGSS (authority, client, principal, conversation, round, credential material, prompt, derivation) are used here with the same meaning and are not redefined.

Source. A process that is authoritative for some set of principals: it verifies their credentials and says who they are. Called a principal source in full.

Registration. The exchange in which a source announces itself and the authority decides whether to accept it. Precedes any conversation.

Domain. The SID namespace a source is authoritative for. Every principal a source may assert lives under it. See §2.10.

Source conversation. One logon's exchange between the authority and a source, distinguished from other concurrent ones by a conversation identifier. Not to be confused with a PGSS Logon conversation, which is between a client and the authority; one of each exists per logon.

Assertion. A source's terminal message stating who a principal is. The only successful outcome a source can produce.

Originator. The verified identity of the process that requested a logon, as established by the authority from the client's connection. Relayed to the source, which cannot learn it any other way.

Service SID. A SID derived from a service's name, placed in that service's token by the init system, and unforgeable by anything else. How a source's identity is established (§2.9).

Membership scope. The constraint on which groups a source may assert. Separate from identity scope, which constrains whose identity it may assert at all. Sections 2.18 to 2.20 exist because these are different questions with different answers.

Relative identifier. In this chapter, a POSIX identifier as a source states it: an offset within the range the authority assigned that source, never an absolute number (§2.20). Where the SID sense is meant — the last sub-authority of a SID — the text says so.

2.3 Sources Dial In

Peios / Peios technical reference / PSPU / Principal Source Interface

The authority listens. Sources connect to it. The authority never initiates a connection to a source.

This is the most consequential shape decision in this chapter, and it is worth being explicit about what it buys.

2.3.1 Why the direction matters #

The authority holds the privilege to mint tokens. It is the most privileged userspace process on the system. An authority that dialled out would need, in its configuration, a list of paths to connect to — and a process holding that privilege having a configurable list of things to go and talk to is a liability out of proportion to the convenience.

Because sources connect inward, the authority's sockets are accept()-only. It never opens an outbound connection to anything, for any reason.

2.3.2 What follows #

Restart is the source's problem. A source whose connection drops reconnects. The authority does not retry, does not queue, and does not track sources it has not heard from. A source that has gone away is simply not registered.

A source is not required to exist. An authority with no registered sources cannot authenticate anybody, and that is a coherent state rather than an error — it means no identity has been made available to it yet.

Ordering is the init system's problem. The authority must be listening before a source can register, and a service that depends on authentication must start after a source has. Expressing that is a service-ordering question, not a protocol one, and this chapter says nothing about it.

Note

A source SHOULD report itself ready only once its registration has been acknowledged, so that "the source is running" and "the source can authenticate" are the same statement to anything ordered after it. This is what makes the Registered acknowledgement load-bearing despite carrying almost nothing.

2.4 Assert, Never Mint

Peios / Peios technical reference / PSPU / Principal Source Interface

A source says who somebody is. It cannot say anything else, and the protocol is built so that this is structural rather than a rule anyone has to remember.

2.4.1 The success terminal #

PGSS Logon's success terminal is AccessGranted, carrying a session identifier and a token descriptor. If PSI reused it, sources would be minting sessions.

PSI's success terminal is Assertion (§2.13), which carries an identity: a SID, a canonical name, and group memberships. There is no session identifier and no descriptor to attach a token to. A source could not mint one if it wanted to, because there is no message in which to say so.

That is the whole of the mechanism. No capability check, no trust level, no configuration flag — a source cannot mint because the protocol gives it no way to express minting.

2.4.2 What the authority keeps #

Everything else:

  • Derivation. What the token actually contains — its privileges, its integrity level, its derived group memberships, its projected identifiers — is the authority's, applying local policy (PGSS §2.1).
  • Session creation. The logon session, and the record of which source vouched for it.
  • Validation. Every SID a source sends is bytes until the authority has checked it (§2.13).
  • Scope enforcement. What a source is permitted to claim (§2.18 to §2.20).
  • Peer verification. On every connection it accepts.
  • Rate and round limits, and the policing of what a source may ask a client for (§2.12).

2.4.3 Why a compromised source is bounded #

A source that is entirely compromised can lie about the principals in its own domain. It cannot mint a token, cannot elevate anyone's privileges, cannot claim identities outside its domain (§2.18), and — unless configured otherwise — cannot assert memberships outside it either (§2.19).

That bound is the reason for the process boundary. It is not that sources are expected to be malicious; it is that a source is the component parsing credentials from the outside world, and therefore the one most likely to be wrong.

2.5 A Superset of PGSS Logon

Peios / Peios technical reference / PSPU / Principal Source Interface

A principal source is an authentication authority for its slice of the world. The authority that federates is an authority over authorities. Once that is seen, most of PSI writes itself.

2.5.1 The relationship #

PSI's interrogation phase is PGSS Logon's, with identical message bodies. CredentialRequest and CredentialResponse carry exactly the bytes PGSS §2.8 defines, and the authority relays them nearly verbatim in both directions.

The consequences are worth stating plainly:

  • The source decides what to ask for. Not the authority. The authority does not know what credentials a source requires, and does not need to.
  • The authority becomes a relay in the interrogation phase. It is a shorter path than synthesising its own prompts, not a longer one.
  • Adding a credential type is a change to sources, not to the authority and not to clients, which already render what they are given (PGSS §2.3).
  • A source could be tested in isolation by pointing a PGSS Logon client at it, for the interrogation phase at least.

2.5.2 Where they diverge, deliberately #

Three differences, each for a stated reason.

The success terminal. Assertion rather than AccessGranted, so that a source cannot mint. See §2.4. This is the divergence that matters.

Multiplexing. PGSS Logon is one conversation per connection; the connection is the conversation. PSI carries many concurrent logons over one long-lived connection, so its header adds a conversation identifier (§2.7). The alternative — serialising every logon behind one connection — would make any slow logon a system-wide login stall.

Distinct magic. PPSI rather than PGSL. Two protocols this similar sharing a codec is a cross-protocol hazard: a socket plugged into the wrong daemon would partially work, which is far worse than failing outright. The magic makes it a hard error on byte zero.

The full accounting of what is shared, what is added and what differs is §2.C.

2.5.3 "Just relaying" is loose #

The authority is a relay in the interrogation phase only, and even there it is not passive. It polices what a source may ask a client for (§2.12), it validates what a source asserts (§2.13), and it enforces scope (§2.18 to §2.20). Everything before and after the interrogation is entirely its own.

2.6 The Channel

Peios / Peios technical reference / PSPU / Principal Source Interface

2.6.1 Socket #

An authority that federates over PSI MUST listen on a SOCK_STREAM Unix domain socket.

Unlike PGSS Logon's path, this one is not normative. PSI is not a conformance requirement (§2.1), and an authority that offers it may put it where it likes provided its sources are told. Mainline's is /run/psi.sock.

2.6.2 Access control #

The socket SHOULD carry a security descriptor. It is DoS protection and nothing more, and an implementation MUST be written as though it were absent.

The reason is that the socket cannot be the boundary. What establishes a source's identity is the peer's token (§2.9), which is checked on every connection. A descriptor that kept casual traffic away would be a convenience; a descriptor relied upon would be a second, weaker access control that someone will eventually assume is doing the work.

An authority MUST therefore bound the number of unregistered connections it will hold open, and the time it will wait for a registration, independently of any descriptor.

Note

An unauthorised peer is refused at the identity check, which is cheap, and the connection cap bounds what it can occupy in the meantime. That is the whole of what the descriptor's absence costs.

2.6.3 Long-lived connections #

A source's connection persists for the life of the source and carries every logon routed to it.

An authority MUST bound the number of registered sources and the number of concurrent conversations per source. A source MUST bound the conversations it will track, and MUST NOT depend on the authority's bookkeeping to do it — a source that trusted the authority's limit would be trusting a bound it cannot verify.

2.6.4 Failure #

A framing error is fatal to the connection, not to a conversation. Once a message has failed to parse there is no way to know where the next one starts, so both parties MUST tear the connection down rather than attempt resynchronisation.

A failed write is likewise fatal. A partial write desynchronises the stream just as a bad frame does, and treating it as a per-conversation error would leave a corrupt connection in use.

Ordinary semantic failures — an unknown principal, a bad credential, a refused logon — are not connection failures. They are Refusal messages (§2.13) and the connection continues.

2.7 Message Framing

Peios / Peios technical reference / PSPU / Principal Source Interface

2.7.1 Header #

Every message begins with a 20-byte header:

OffsetSizeFieldValue
04magicPPSI (50 50 53 49)
42version1
62msg_typeSee §2.A
84total_lenHeader plus body, in bytes
128conversationSee below

The first twelve bytes are PGSS Logon's header, unchanged and at the same offsets. total_len in particular sits where PGSS §2.6 puts it, which is what lets one transport implementation frame either protocol off a stream.

2.7.2 Magic #

PPSI, checked on every message, fatal to the connection when wrong.

This matters more here than it would for an unrelated protocol, because PSI and PGSS Logon share message bodies (§2.5). A CredentialRequest from one is byte-identical to the other's. Without distinct magic, a socket plugged into the wrong daemon would decode several fields correctly before going wrong — which is the failure mode hardest to diagnose and easiest to miss.

2.7.3 Conversation identifier #

The conversation field distinguishes concurrent logons on one connection.

  • Conversation 0 is reserved for connection-level messages: Register, Registered and Changed (§2.17). It MUST NOT be used for a logon or a query.
  • Logon and query conversations use identifiers from 1 upward, drawn from one space.
  • The authority allocates them. A source MUST NOT invent one, and MUST reply on the identifier it was given.
  • An identifier is unique among live conversations on one connection. An authority MAY reuse one after a conversation has reached a terminal state.

A source MUST reject a message on a conversation it does not know, and MUST NOT treat it as opening a new one. Only Authenticate (§2.11), Query (§2.15) and EnumerateSource (§2.16) open one, and an authority MUST NOT open one with an identifier already live.

Rejecting means declining to act on it. A source MUST NOT reply on a conversation it does not know: an authority MAY have reused the identifier after a terminal state, so a reply could arrive as a second terminal message for a conversation that has already ended. Discarding it, and recording that it happened, is the whole of the obligation.

A source MUST likewise refuse an Authenticate, Query or EnumerateSource arriving on conversation 0, which is reserved.

2.7.4 Size limit #

A message MUST NOT exceed 81920 bytes — larger than PGSS Logon's ceiling, because a PSI message wraps one.

2.7.5 Message direction #

The high bit of msg_type marks a message sent by the source. This follows PGSS Logon's convention that the bit marks the authority for the matter at hand: on this interface the source is the authority for its own principals, and the logon authority is the one asking.

2.7.6 Encoding #

PSI shares its codec with PGSS Logon, and PGSS §2.6's encoding rules apply here unchanged: little-endian multi-byte integers; UTF-8 strings, length-prefixed and never NUL-terminated; length-framed structures and array elements, skipped to their declared end; u32 element counts on arrays, with stated maxima binding on encoder and decoder alike.

2.7.6.1 SIDs on the wire #

SIDs are carried as opaque bytes, in the binary self-relative form PCDS specifies, never as text.

They are opaque to the codec, which has no business knowing what a SID is. They are emphatically not opaque to the authority, which MUST validate every SID it receives before treating it as identity (§2.13). The obligation to check sits in the process that mints tokens, not in the layer that moves bytes.

A SID MUST NOT exceed 68 bytes — the eight-byte prelude plus fifteen sub-authorities, which is the most the encoding's one-byte count admits.

2.7.7 Body extensibility #

The extensibility rules of PGSS §2.6 apply unchanged: fields are appended only, a new field is optional with a safe default, and a new enumeration value is a breaking change requiring a version bump. The one exception is the capability bitmask of §2.8, for the reason given in §2.B.

One PSI-specific application deserves stating. Authenticate (§2.11) nests a whole LogonStart inside its own length frame rather than inlining its fields. The obvious encoding — LogonStart's fields, then PSI's — is wrong: LogonStart belongs to PGSS Logon and grows on PGSS Logon's schedule, so a field appended there would silently displace the field after it. Nesting lets the two evolve independently.

The same reasoning applies to every shared body PSI carries, and §2.C lists them.

2.7.8 Initial unpublished layout replacement #

The coordinated SSH credential extension replaced the unpublished version-1 layout without retaining older codecs. Current LogonStart capability, required-method and binding fields, Prompt parameters, and all current Assertion fields are mandatory, including explicit empty/zero values. Omitted mandatory fields fail decoding. The compatibility rules above apply to future extensions of this replacement contract; they do not require accepting the superseded development layout. See PGSS Logon §2.D.

2.8 Registration

Peios / Peios technical reference / PSPU / Principal Source Interface

A connection opens with registration. Nothing else may precede it.

2.8.1 Register #

msg_type = 0x8001. Source to authority, on conversation 0.

FieldEncodingLimit
source_namestring32 bytes
domainlength-framed bytes (SID)68 bytes
capabilitiesu32§2.B
entry_ttlu32seconds
max_batchu3264

2.8.1.1 source_name #

What the source calls itself. Bounded at 32 bytes because it becomes the authentication-package name on every session the source authenticates — so a token's provenance answers which source vouched for this? rather than merely the authority minted it.

The name is a claim. It MUST be cross-checked against the identity the authority established for itself (§2.9), and it MUST NOT be used for anything else. A mismatch MUST be refused rather than quietly corrected: a service registering under another's name is worth failing on, not normalising.

2.8.1.2 domain #

The SID namespace this source is authoritative for (§2.10).

2.8.1.3 capabilities #

What the source can do beyond authenticating.

BitNameMeaning
0QUERIESAnswers Query (§2.15).
1ENUMERATESAnswers EnumerateSource (§2.16).
2MEMBERSCan produce a group's membership.
3PUSHES_CHANGESSends Changed (§2.17).
4CHANGES_CREDENTIALSAnswers ChangeCredential (§2.21).
5ENROLLS_CREDENTIALSAnswers EnrollCredential (§2.23).

An authority MUST NOT send a message a source did not declare it answers, and MUST NOT set a field bit gating a capability the source did not declare. A source declaring nothing authenticates and does nothing else, which is what a source predating these fields is saying by omission — and is the only reading that keeps such a source working.

A source that declares no QUERIES cannot be asked about its principals outside a logon, so they are unresolvable through PGSS Logon's identity channel: they can sign in and will appear as bare numbers everywhere else. That is a coherent configuration and this chapter permits it, but it is almost never what an administrator intended, and an authority SHOULD report it where one will see it — as it reports a source registering with no identifier range.

This is a declaration of capability, not of willingness. A source declaring QUERIES may still answer Refused to any particular question (§2.15); a source declaring ENUMERATES may still refuse a cursor it can no longer honour (§2.16).

2.8.1.4 entry_ttl #

How long, in seconds, the authority may hold an answer from this source before asking again.

Zero means do not cache. A source declaring neither PUSHES_CHANGES nor a non-zero entry_ttl has said its answers must not be held at all, and an authority MUST honour that — see §2.17, where the reasoning for reading silence that way is set out.

entry_ttl and PUSHES_CHANGES are not exclusive, and a source declaring the second SHOULD declare a non-zero first as well. See §2.17: the TTL is the backstop against a notification that was never sent.

2.8.1.5 max_batch #

The largest number of keys the source will accept in one Query (§2.15). Zero means one.

An authority MUST NOT exceed it, and MUST NOT send more than 64 keys whatever the source declared: an encoder MUST NOT declare more, and a decoder MUST read a larger declaration as 64.

A source MUST still validate what it receives. The field is a hint the authority is required to respect, not a guarantee about what will arrive.

2.8.2 Registered #

msg_type = 0x0001. Authority to source, on conversation 0.

FieldEncoding
unix_id_baseu32
unix_id_countu32

Load-bearing beyond its contents. A source SHOULD report itself ready only once it has received this, so that anything ordered after the source finds a system that can actually authenticate rather than merely a process that exists (§2.3).

2.8.2.1 unix_id_base, unix_id_count #

The POSIX identifier range the authority has assigned this source (§2.20). A base of 0 means no range was assigned, and every principal the source asserts will project as unmapped.

Informational. A source counts within its range and asserts relative identifiers; the authority applies the base. A source MUST NOT apply it — see §2.20, where the reasoning is set out in full.

It is sent so that a source's administration tools can show an operator the identifier a principal will really project to, rather than the relative number the source stores. Without it that arithmetic falls to the operator.

An authority that predates these fields sends neither, and a source MUST read their absence as no range assigned — which is what such an authority means, since it has no ranges to assign.

2.8.3 Rules #

  1. A connection MUST open with Register on conversation 0. An authority MUST refuse any connection that opens otherwise.
  2. An authority MUST bound the time it waits for the opening Register, and close the connection on expiry. A peer that connects and says nothing MUST NOT be able to hold resources indefinitely.
  3. An authority MUST NOT admit two sources under one name at the same time.
  4. An authority MUST send Registered only after the source is routable, so that a logon racing the acknowledgement cannot find a source that is registered but not yet reachable.
  5. A source MUST NOT send any other message before receiving Registered.
  6. An authority SHOULD report, where an administrator will see it, that a source registered with no identifier range — its principals will all project as unmapped, and the cause is a configuration omission rather than anything the source did.

2.9 Establishing What a Source Is

Peios / Peios technical reference / PSPU / Principal Source Interface

The authority MUST establish a connecting source's identity for itself, from the kernel, and MUST NOT take it from anything the source sends.

2.9.1 A source proves nothing #

The mechanism worth recommending is that a source proves nothing at all, because the init system already did.

Where the init system places a service SID in each service's token — a SID derived from the service's name, which only the init system can mint — the authority can:

  1. take its list of permitted source names from its own configuration;
  2. derive the service SID each of those names implies;
  3. read the connecting peer's token and ask which of those SIDs it carries.

The resulting identity is assembled entirely from the authority's configuration and the kernel. Nothing is contributed by the process on the other end. There is no shared secret, nothing to provision, nothing to rotate, and nothing to steal — the derivation is a pure function of a name that only the init system can act on.

Note

Mainline derives S-1-5-80-<SHA-1 of the uppercased UTF-16LE service name>, matching what the init system places in every service token. The derivation exists so that platform services — all running as SYSTEM — remain distinguishable from one another, which is exactly this problem.

2.9.2 What is deliberately not checked #

That the peer is SYSTEM. The service SID subsumes it: the user SID could never distinguish one platform service from another, since they all run as the same principal. Requiring SYSTEM as well would needlessly forbid a future source running under a lesser account, which is a direction worth keeping open.

2.9.3 The allowlist #

An authority MUST NOT accept a source it has not been configured to accept.

An empty configuration MUST mean no source may register, not any source may. An allowlist that fails open is not an allowlist. The visible cost is that a system configured with no sources cannot authenticate anyone, which is the correct way for that mistake to present — loudly, at the first logon attempt, rather than silently at the first compromise.

Note

Mainline's allowlist is a registry key whose subkey names are the permitted source names, so enumerating that key answers "what may assert identity on this machine?" exactly, with no second list anywhere to drift out of step.

2.10 The Domain Claim

Peios / Peios technical reference / PSPU / Principal Source Interface

A source declares the domain it is authoritative for. Every principal it may assert lives under it (§2.18).

2.10.1 It is a claim #

A source generates or is given its own domain, so nothing about the SID can prove the claim is honest. What gives it weight is entirely what the authority does with it.

An authority MUST apply all of the following.

2.10.1.1 1. A domain MUST be declared #

A source that declares no domain MUST be refused. There would be nothing to confine its assertions to, which is the whole purpose of collecting one.

Note

The field is appended to a message that existed before it, so a source built against an older shape decodes as declaring nothing rather than as malformed. That is why the refusal is stated as its own rule: the diagnosis should name the real problem.

2.10.1.2 2. The shape MUST be checked #

A domain MUST be a well-formed, locally-issued domain SID: revision 1, the NT authority (5), the non-unique prefix 21, and three further sub-authorities — S-1-5-21-A-B-C, four sub-authorities in total.

The shape is the entire check, and it is enough. A source cannot claim S-1-5-32 (BUILTIN), or the NT authority's well-known range, or S-1-1-0, because none of them has that shape. There is no list of forbidden domains to keep in step with the SID catalogue — the permitted shape excludes every one of them by construction.

2.10.1.3 3. Domains MUST be disjoint #

No two concurrently registered sources may claim the same domain. Two authorities for one namespace means whichever answers first decides who a name belongs to, and the other's principals become impersonable by the first.

2.10.1.4 4. A source MUST NOT change domain #

A source that re-registers MUST declare what it declared before. An authority MUST refuse a change.

This is the check that survives a source restarting, and it is worth its cost: every other check passes for a source that is killed and comes back compromised. It still holds the right service SID, its new domain is still a claimable shape, and with itself deregistered there is nothing left to collide with.

An authority MAY hold this record only for its own lifetime. Persisting it means the authority writing state, which is a larger commitment than the guarantee justifies.

2.10.1.5 5. An administrator MAY pin #

An authority SHOULD allow an administrator to configure the exact domain a named source must declare, and MUST refuse a source declaring anything else when one is configured.

A configured pin that cannot be parsed MUST NOT be treated as absent. Absence means no pin; an unparseable value means an administrator tried to apply the control and got it wrong, and silently downgrading that to "unconstrained" removes the control at the moment it was being applied. An authority MUST fail towards refusing the source.

2.10.2 What remains uncovered #

With no pin configured, and another source not currently registered, a compromised source could declare that source's domain and assert its identities. Disjointness catches it only while both are registered.

This is stated rather than solved. Closing it requires someone to write the pin down, and an authority cannot invent that authority for itself — writing it automatically would mean the process holding the token-minting privilege also holding a configuration write handle, which is a worse trade than the gap it closes.

2.11 Authenticate

Peios / Peios technical reference / PSPU / Principal Source Interface

msg_type = 0x0002. Authority to source. Opens a conversation.

FieldEncodingLimit
startnested LogonStart, length-framedPGSS §2.7
originatorlength-framed bytes (SID)68 bytes

2.11.1 start #

The client's LogonStart, nested whole (§2.7). Its fields and their meanings are PGSS §2.7's, unchanged — including that identifier is an unverified claim and supported_credential_types binds what may be prompted for.

2.11.2 originator #

The verified identity of the process that requested this logon, taken by the authority from the client's connected socket and never from a message body.

A source cannot learn this for itself: it is not party to the client's connection, and there is nothing it could ask. The authority relays it because a source may legitimately refuse a logon on the strength of it — an account restricted to console logons needs to know what asked — and that decision needs a trustworthy input.

A source MUST treat originator as established fact and MUST NOT treat any other field of this message the same way.

2.11.3 Routing #

Before sending Authenticate, an authority MUST decide which single source answers.

The credential MUST NOT be offered to more than one source. Trying each in turn with the password hands every source the credentials of every other source's users, including on typos — the failure PAM stacking exemplifies (§2.D).

Resolution therefore happens on the identifier, before any credential exists. Asking several sources "do you own this name?" is a resolution step with no secret in it and is permitted; offering them the answer is not.

An authority SHOULD resolve a qualified name to its owning source and MUST NOT fall back to another source when the owning one is unreachable. A name that can fall through lets anyone who can break a network choose which authority answers for a principal.

2.11.4 Conversation limits #

An authority MUST bound the conversations it opens against one source. A source MUST bound what it will track, and MUST refuse beyond its own limit with AuthorityUnavailable (§2.13) rather than dropping the conversation silently.

2.12 The Relayed Interrogation

Peios / Peios technical reference / PSPU / Principal Source Interface

Two messages, both carrying PGSS Logon bodies verbatim. They serve a credential change (§2.21) exactly as they serve an authentication, and everything below applies to both.

2.12.1 CredentialRequest #

msg_type = 0x8002. Source to authority. Body is PGSS §2.8's CredentialRequest, byte-for-byte.

The source decides what to ask for, in what order, and over how many rounds. The authority relays it to the client.

2.12.2 CredentialResponse #

msg_type = 0x0003. Authority to source. Body is PGSS §2.8's CredentialResponse, byte-for-byte.

2.12.3 What the authority polices #

The authority relays, but does not relay anything.

An authority MUST refuse to relay a prompt whose credential type is absent from the client's supported_credential_types. PGSS §2.8 makes this the authority's obligation towards the client, and it holds however the authority reached the prompt — a prompt originating in a source is still the authority's to police.

Relaying it would force the client to hard-fail, and a client that guessed instead might echo a secret to the screen. The authority MUST terminate the logon instead.

An authority MUST also enforce its own round and time limits on the relayed exchange (PGSS §2.3), independently of any the source applies. A source that never terminates a conversation MUST NOT be able to hold a client's logon open indefinitely.

2.12.4 Credential handling #

The obligations of PGSS §2.12 bind both parties on this leg as they do on the client's. Credential material reaching a source has been decoded and re-encoded once more than it would have been without federation, and every buffer it passed through on the way is one the obligation covers.

2.12.5 What the authority does not do #

It does not interpret prompts, rewrite messages, reorder anything, or synthesise a request of its own. A source's prompt reaches the client as the source wrote it, which is the property that makes adding a credential type a change to sources alone.

2.13 Assertion and Refusal

Peios / Peios technical reference / PSPU / Principal Source Interface

Exactly one terminal message ends a source conversation. An authentication ends in Assertion or Refusal. A credential change ends in CredentialChanged or Refusal, never in Assertion (§2.21).

2.13.1 Assertion #

msg_type = 0x8003. Source to authority. The only successful outcome a source can produce.

FieldEncodingLimit
user_sidlength-framed bytes (SID)68 bytes
canonical_namestring256 bytes
groupsarray of group entries128
unix_idu32§2.20
primary_grouplength-framed bytes (SID)68 bytes
profilelength-framed structure (PGSS §2.9)
claimsarray of claim entries64
permitted_logon_typesu32PGSS §2.16
authenticated_credential_typeu8; 0 none, 1 Password, 2 SshPublicKeyPGSS §2.D

Note what is absent: no session, no token, no privileges, no integrity level. A source has no way to express them (§2.4).

All listed fields are mandatory in the coordinated replacement layout. Empty arrays/strings and zero values represent absent assertions; omitted fields are malformed. The authority checks the authenticated method against the client's capabilities and any required method before granting a token.

2.13.1.1 canonical_name #

The source's own spelling of the principal's name. A client may have typed JACK; this is what the principal is actually called.

Carrying it is what makes case-insensitive matching safe: the authority records the canonical form rather than whatever was typed, so a session's records do not vary with a caller's shift key.

A source MUST NOT assert a name that PGSS §2.15 forbids — one carrying a reserved character, a byte outside the printable ASCII range, or a leading or trailing space. The authority MUST refuse one anyway (§2.22), because a name from a source reaches a passwd-format record and an audit line, and by then the damage is the reader's to do.

The obligation is on what a source asserts, not on what it creates. A source that validates a name when an administrator adds it, and not when it reads one back from storage, has enforced nothing against a store it did not itself write.

2.13.1.2 permitted_logon_types #

Which kinds of sign-on this principal may be used for, as the bitmask PGSS §2.16 defines for the LOGON_TYPES field.

A source states the property and MUST NOT act on it. Deciding what to do about a principal's restrictions is derivation, and derivation is the authority's (§2.4) — a source that refused an authentication on these grounds would be making an access decision it has no standing to make, and would do it differently from the authority that also has to.

Zero means the source states nothing, and a source that does not hold the property MUST send zero rather than inventing a value. In particular it MUST NOT send "everything", which would assert that a principal may be used for a credential-free service logon (PGSS §2.19) on no evidence at all.

Sources that state no restriction send zero explicitly.

2.13.1.3 groups #

Each entry is a separate length-framed structure:

FieldEncodingLimit
sidlength-framed bytes (SID)68 bytes
unix_idu32§2.20

A SID and a number. No attributes.

A source asserts which groups a principal belongs to. Whether a group entry is enabled, owner-marked, or deny-only is a decision about how to build a token, and building tokens is the authority's (§2.4). A source saying "this principal is an administrator" is identity; a source saying "and mark that group deny-only" would be reaching into derivation.

Both current group fields are mandatory. The per-entry framing preserves the boundary of any future trailing extension.

A unix_id of 0 means the source does not number this group — the honest answer for a group it does not own. A source naming a well-known group is stating a membership, not claiming authority over what that group projects to; see §2.20.

2.13.1.4 unix_id #

The principal's POSIX identifier, relative to the range the authority assigned this source (§2.20). Zero means the source has no number for this principal.

A source MUST NOT apply its own base. It counts within its range and the authority rebases; a source that added the base itself would have it added twice.

2.13.1.5 primary_group #

Which of the principal's groups projects to the POSIX group id, and becomes the default group of objects the token creates. Empty means the source did not say, and the authority chooses.

It need not appear in groups. The authority is required to place it on the token regardless (§2.22), because a token's primary group must be a group the token carries — so naming a group here is a membership claim, and it is subject to membership scope exactly as a listed group is (§2.19).

That applies to a primary group the source asserted. Where the field is empty and the authority substitutes one of its own, the substituted value is the authority's choice and MUST NOT be tested against the source's membership scope. Testing it would deny every logon from a source that declined to name a primary group, on the strength of a claim that source never made — and an authority's own default is very unlikely to be a sibling of the principal's domain, so the test all but always fails.

Note

A source that made this the one field escaping the scope check would have found a route to BUILTIN\Administrators that the group array denies it. The obligation in §2.22 to apply membership scope to the primary group closes that.

2.13.1.6 profile #

PGSS Logon's profile structure (PGSS §2.9), relayed onward to the client unchanged. It is not identity, it decides no access, and the authority does not interpret it.

The one thing the authority does check is the one PGSS §2.9 requires of it: home and shell, when non-empty, are absolute paths. That obligation binds the authority towards its client whatever the value's provenance, so a relayed profile is not exempt from it.

2.13.1.7 claims #

Named, typed attributes fed to conditional ACE evaluation, in the claim attribute format PCDS §5.9 specifies. Each entry is a separate length-framed structure:

FieldEncodingLimit
namestring255 bytes
flagsu32PCDS §5.9
value_typeu32PCDS §5.9
valuesarray of length-framed values64

A claim is the one field here that is a trusted input to access decisions rather than a statement of identity: a conditional ACE can turn a claim into a grant. Which claim names a source may assert is therefore the same kind of question as which groups it may assert, and belongs with membership scope (§2.19).

An authority MUST reject an assertion carrying a claim it cannot carry to a token — an unsupported value type, a name containing an interior NUL, a value exceeding its limit — rather than dropping the claim. The reasoning is rule 3 below: a dropped claim signs the principal in against a policy nobody stated.

2.13.1.8 What the authority MUST do with an assertion #

  1. Validate every SID with a structural check before treating it as identity. Bytes from another process are bytes until checked, and the check belongs in the process that mints tokens rather than in the codec that moved them (§2.7). This includes SIDs carried inside a claim value.
  2. Enforce identity scope (§2.18), membership scope (§2.19) — including over primary_group — and numeric scope (§2.20).
  3. Fail the logon on a malformed group SID, an unusable claim, an invalid canonical_name, or a unix_id outside the source's range, rather than dropping it. Dropping would sign the principal in with authority the source did not state — a confusing way to be wrong at best, and for a claim, a silent change of the policy that will be applied to them. A source that cannot encode a SID is broken.
  4. Drop a logon SID from the asserted groups, loudly. No source is authoritative for one: the kernel mints them per session, and this session's did not exist when the source answered. A source asserting one is either buggy or reaching for a different session's SID, which would forge membership of somebody else's logon.
  5. Drop duplicates, first mention winning. A source asserting a group the authority also derives is redundant, not wrong.

Rules 4 and 5 drop rather than refuse because the token still ends up correct, and refusing would punish a principal for a source's defect without making anything safer. Rule 3 refuses because the token would not end up correct.

Rules 4 and 5 run before rule 2. A logon SID is by construction outside the principal's domain, so an authority that applied membership scope first would refuse the logon that rule 4 says to survive by dropping. Duplicates are the same shape of problem. The drops are about what a source should never have sent; the scope tests are about what it is permitted to claim, and only what survives the first is subject to the second.

An authority MUST also place primary_group on the token even when the source did not list it among groups, since a token's primary group must be a group the token carries.

2.13.2 Refusal #

msg_type = 0x8004. Source to authority.

FieldEncodingLimit
denialu32PGSS §2.B
reasonstring512 bytes

Reuses PGSS Logon's denial vocabulary rather than inventing a parallel one, so that relaying a refusal outward needs no lossy translation.

A source MUST NOT refuse with NoSuchSession (PGSS §2.B). It answers only a request to end a logon session (PGSS §2.22), which the authority decides alone and never puts to a source, so in a refusal it can only be a mistake. An authority that receives it MUST NOT relay it, and SHOULD end the conversation with Internal instead.

CredentialRejected is the same kind of code: it answers only an enrolment (PGSS §2.23), so a source MUST NOT send it in any other conversation, and an authority that receives it elsewhere MUST NOT relay it (§2.23).

A source MUST NOT distinguish an unknown principal from a bad credential — by code, by reason, or by timing (PGSS §2.10, §2.12). The obligation is the source's here, because the source is where the distinction exists to be leaked.

Note

A principal requiring no credential is a separate matter, and cannot be hidden: a conversation that reaches Assertion with no round at all says that the named principal exists and needs nothing. That is inherent in permitting a zero-round logon rather than a defect in this rule, and it is the reason a passwordless principal is a configuration decision rather than a convenience.

An authority MAY relay reason to the client and MAY replace it. It MUST NOT relay a reason that reveals a distinction the source was required not to make.

2.14 Abandon

Peios / Peios technical reference / PSPU / Principal Source Interface

msg_type = 0x0004. Authority to source. No fields beyond the header.

Tells a source that a conversation will not continue: the client hung up, a limit was reached, or the authority terminated the logon for reasons of its own.

2.14.1 Why it exists #

Without it, a client that disconnects mid-prompt leaves the source holding conversation state forever. A source cannot detect this for itself — it is not party to the client's connection — so the authority has to say.

2.14.2 Rules #

  1. An authority MUST send Abandon for any conversation it opened that will not reach a terminal state, unless the connection itself is being torn down.
  2. A source MUST discard all state for the conversation on receipt, and MUST NOT reply.
  3. Abandon is not a terminal message from the source, and no Assertion or Refusal follows it. If one arrives anyway, the authority MUST ignore it — the conversation is gone, and a late answer to an abandoned question is at best stale.
  4. A source that receives Abandon for a conversation it does not know MUST ignore it. It has already cleaned up, which is the outcome the message wanted.

Note

The natural implementation is to send Abandon from whatever owns the conversation's lifetime, on any path that does not reach a terminal state — including error paths. An authority that sends it only on the tidy path will leak conversations exactly when things are already going wrong.

2.15 Query

Peios / Peios technical reference / PSPU / Principal Source Interface

An authority serving PGSS Logon's identity lookup must be able to ask a source about a principal outside a logon: to render a name for a SID, or a POSIX record for a number.

A query is a conversation like any other. The authority allocates the identifier, Query opens it, and one terminal message closes it.

2.15.1 Query #

msg_type = 0x0005. Authority to source. Opens a conversation.

FieldEncodingLimit
fieldsu32PGSS §2.B
keysarray of key entries64

A key entry is a length-framed structure:

FieldEncodingLimit
key_typeu8§2.B
namestring256 bytes
sidlength-framed bytes (SID)68 bytes
relative_idu32
kindu8PGSS §2.B

Exactly one of name, sid and relative_id is meaningful, selected by key_type. An encoder MUST leave the others empty or zero.

2.15.1.1 Keys are never absolute #

ValueName
1Name
2Sid
3RelativeId

There is no key type carrying an absolute POSIX identifier, and this is the load-bearing property of the message.

PGSS Logon's lookup accepts one, because that is what getpwuid hands a name resolver. The authority resolves it: it locates the range containing the number, subtracts the base, and asks the owning source by relative identifier.

A source is therefore never asked an absolute number, exactly as it never asserts one during a logon (§2.20).

The reason is not that an absolute number would let a source escape its range — it could not, because the authority refuses a relative identifier at or past the count before adding anything to it (§2.20). The reason is that the arithmetic must exist in exactly one place. A source asked an absolute number would have to subtract its own base to answer, which is the operation §2.20 forbids it, and an authority that asked would have taught it that its stored numbers and the system's are the same numbers. Every subsequent bug in that source would be an off-by-a-base.

Note

This is also why the authority is the only party that can answer a getpwuid. See PGSS §2.13.

2.15.1.2 Batching #

keys is an array so that an authority may ask several questions in one exchange. An authority MAY send a single key, and one that always does is conforming.

The array is here from the outset because adding it later would break every source written against a single-key message. A source with a cheap local store gains little; a source backed by a remote directory gains the difference between one query and a hundred.

A source MUST answer every key it is sent, in order, and MUST NOT reorder, merge or omit results. A source that cannot serve a whole batch MUST refuse the conversation rather than answer part of it.

2.15.2 QueryResult #

msg_type = 0x8005. Source to authority. Terminal.

FieldEncodingLimit
resultsarray of result entries64

One result per key, in the order the keys were sent.

A result entry is a length-framed structure:

FieldEncodingLimit
outcomeu8§2.B
sidlength-framed bytes (SID)68 bytes
canonical_namestring256 bytes
kindu8PGSS §2.B
presentu32PGSS §2.B
withheldarray of withheld entries32
valuesarray of length-framed values32

Everything from present onward is PGSS §2.16's structure, unchanged — the same reuse of message bodies as the interrogation phase (§2.5), and for the same reason: an authority relaying a source's answer outward should not have to translate it.

Where outcome is not Found, everything after it MUST be empty or zero.

2.15.2.1 canonical_name, not qualified #

A source returns its own spelling of the name, as it does in an Assertion (§2.13). It does not qualify it.

Qualification names which source answered, and a source cannot know what it is called in another authority's search order. PGSS Logon requires a qualified name on the way out (PGSS §2.15); producing it is the authority's.

2.15.2.2 Identifiers are relative #

Every identifier in a result — a UNIX_ID field, a reference's unix_id — is relative, exactly as in an Assertion (§2.20). A source MUST NOT apply a base, and the authority rebases before the number leaves it.

2.15.2.3 Outcomes #

A source may send only:

ValueNameMeaning
1FoundThe source holds this object.
2NotFoundIt does not.
4RefusedIt holds it and will not say so.

A source MUST NOT send Unavailable: it is answering, so nothing was unavailable to it. The authority produces that outcome when a source does not answer (PGSS §2.18), and a source claiming it would let a working source be recorded as a broken one.

A source MUST NOT send Malformed in a result. A message it cannot parse is a Refusal for the whole conversation (§2.13).

Note

Refused is how a source declines to expose a principal it holds — which §2.22 has always permitted. It is distinct from NotFound because the authority may consult another source on NotFound, and must not on Refused: the object was found, and the answer was no.

A Refused result is about the object, not about the caller, and an authority MUST NOT relay it outward as PGSS Logon's Refused outcome, which is reserved for a caller that may not make the request (PGSS §2.18). What a source declining to expose an object means to a client is the authority's to decide, and it is not "you lack permission".

A source that declared no QUERIES is a third case again. It has not answered and cannot be asked, so an authority MUST NOT record it as having answered NotFound: a source that was never consulted is not evidence that an object does not exist, and PGSS §2.18 forbids reporting NotFound on the strength of one. It contributes nothing to the search, and §2.8 says what an authority should do about the configuration that produced it.

2.15.3 Scope #

An authority MUST apply identity confinement (§2.18), membership scope (§2.19) and numeric scope (§2.20) to a QueryResult exactly as to an Assertion, and MUST validate every SID in one structurally before using it — including the SIDs of references inside a PRIMARY_GROUP, GROUPS or MEMBERS value, not only the sid of the result itself.

"Exactly as to an Assertion" is meant literally, and it is the sentence an implementation is most likely to satisfy by halves. An authority that confines the object a result names, while relaying the group references beside it unchecked, has left the whole of membership scope unenforced on this channel — and a source with no permission to assert a foreign membership can then report one through a name lookup that it could not report through a logon.

A query is not a weaker channel than a logon. A source that could name a principal outside its domain here would be able to make ls -l display another source's principals as its own — and, worse, could then be believed the next time something compared that name to a SID.

2.16 Enumeration

Peios / Peios technical reference / PSPU / Principal Source Interface

Query asks about objects the authority can already name. Enumeration asks a source to produce them: to fill a POSIX passwd or group table, or to page through a group whose membership will not fit in one answer.

A source is never required to enumerate. A source that declines is fully conforming, and this section exists as much to make declining safe as to make enumerating possible.

2.16.1 EnumerateSource #

msg_type = 0x0006. Authority to source. Opens a conversation.

FieldEncodingLimit
kindu8PGSS §2.B
fieldsu32PGSS §2.B
oflength-framed key entry (§2.15)
cursorlength-framed bytes256 bytes

kind MUST NOT be Any.

An empty of enumerates every object of kind the source holds. A non-empty of MUST name a group, and enumerates that group's members.

That group is one the source holds, or a BUILTIN group (S-1-5-32-…) named by its SID. A BUILTIN group is no source's object, but a source permitted to assert memberships outside its domain records who is in it (§2.19), and the authority asks every such source for its members (PGSS §2.16). Such a source MUST answer with the principals it records in the group — by a listed membership or as their primary group — and with an empty Found where it records none.

cursor is empty on the first request, and otherwise carries the next from the source's immediately preceding reply.

2.16.2 EnumerateResult #

msg_type = 0x8006. Source to authority. Terminal.

FieldEncodingLimit
outcomeu8§2.B
entriesarray of result entries (§2.15)256
nextlength-framed bytes256 bytes

An empty next ends the enumeration. A non-empty next means there is more, even where entries is empty.

A source MUST size a page against the smaller of this chapter's message ceiling and PGSS Logon's, because the authority re-encodes what it returns into the latter (§2.A). Neither the 256-entry bound nor the message ceiling here prevents a page nobody can deliver.

A source that will not enumerate replies Refused, with entries and next empty. An authority MUST record it as a source that did not contribute, and MUST NOT retry it for the remainder of that enumeration — across pages as well as within one (PGSS §2.17).

Refused and an empty Found are different answers and MUST NOT be conflated. Found with entries and next both empty says there are none: the group exists and has no recorded members, or the source holds no objects of that kind. Refused says this source is not answering. A source that returns an empty Found where it means the second has told the authority a falsehood it cannot detect, and the authority will go on asking it — the non-retry rule above has nothing to attach to.

The cases most often got wrong, all of which are Refused and not an empty Found: a group whose membership the source will not expose, a key that names an object the source does not hold, a key that names a principal where a group was required, and a cursor the source can no longer honour.

2.16.3 Cursors belong to the source #

A cursor is opaque to the authority. The authority MUST NOT construct, parse or modify one; it relays what it was given.

A source MAY encode anything into a cursor, and MAY refuse one it no longer honours — a store rewritten underneath a half-finished walk is the ordinary case, not an exceptional one. A refused cursor is Refused, and the authority MUST NOT restart the enumeration on the source's behalf.

Note

Restarting would turn a store edit during a getent passwd into an unbounded loop over a source that never finishes. Reporting an incomplete enumeration is the honest outcome, and PGSS §2.17 requires the authority to say so.

A source MUST NOT assume a cursor comes back on the same conversation, or on the same connection, and MUST NOT hold per-cursor state it is unwilling to discard.

The authority's own cursor, the one it hands its client, is its to construct — PGSS §2.17 requires it to reject one it did not issue, which it can only do for a cursor it made. A source's cursor travels inside it, not as it.

2.16.4 Why members are here and not only in Query #

MEMBERS is a field of Query (§2.15), so the common case — a small group, whose members fit alongside the rest of the record — costs one exchange.

A group whose membership will not fit is reported through PGSS Logon's TooLarge (PGSS §2.16), and this is where the caller is sent. Paging a membership through the mechanism that already pages is cheaper than a third message, and considerably cheaper than the alternative of a partial member list, which is a wrong answer rather than a smaller one.

2.16.5 Enumeration is not existence #

An authority MUST NOT use enumeration to determine whether a principal exists, and MUST NOT infer from a source declining to enumerate that the source holds nothing.

A directory-backed source able to answer any single question while quite unable to answer all of them is the expected case, not a degraded one.

2.17 Change Notification

Peios / Peios technical reference / PSPU / Principal Source Interface

An authority answering a name lookup for every process on the system will cache. This is the message that lets it.

2.17.1 Changed #

msg_type = 0x8007. Source to authority, on conversation 0. Unsolicited, and never answered.

FieldEncodingLimit
scopeu8§2.B
sidlength-framed bytes (SID)68 bytes
scopeNameMeaning
1AllEverything this source holds may have changed.
2ObjectThe object named by sid may have changed.

sid is meaningful only for Object, and MUST be within the source's declared domain (§2.18).

Object scope MUST be used for a deletion as well as a change, and for a creation — an authority may be holding a cached NotFound for a name that now exists.

2.17.2 Obligations #

A source declaring PUSHES_CHANGES (§2.8) MUST send Changed before, or at the same time as, the altered answer becomes observable through Query.

Sending it afterwards leaves a window in which the authority's cache and the source disagree while both believe themselves current — which is indistinguishable, from the authority's side, from the notification never arriving.

A source MAY send All where it could have sent Object. Over-invalidation costs a query; under-invalidation costs correctness.

An authority MUST accept Changed at any time after Registered, including while conversations are open on the same connection, and MUST NOT reply to it.

An authority MUST treat the loss of a source's connection as All for that source. It does not know what changed while it was not listening.

A source declaring PUSHES_CHANGES SHOULD declare a non-zero entry_ttl as well (§2.8). The two are not alternatives: the TTL is the backstop against a notification that was never sent, or was sent and failed to write. Declaring PUSHES_CHANGES with a TTL of zero means a single lost notification leaves the authority holding a stale answer until the connection drops — and a source that tolerated a failed Changed write without tearing the connection down would have made that outcome reachable, which is one of the reasons §2.6 makes a failed write fatal.

2.17.3 Sources that do not push #

A source that does not declare PUSHES_CHANGES is conforming, and many cannot: a remote directory has no way to tell this machine that an account was renamed.

Such a source declares entry_ttl instead (§2.8), and an authority MUST NOT hold its answers beyond it.

A source declaring neither has said it cannot support caching, and an authority MUST NOT cache its answers at all. That is the safe reading of silence, and it is what a source predating this message says by omission.

Note

The alternative default — cache anything not explicitly forbidden — would make a source written against an older revision silently serve stale identity, which is the one class of staleness that decides access.

An authority that does not cache at all satisfies this section trivially, and is conforming. The obligations here bind what an authority may hold, not whether it must hold anything.

2.17.4 What this does not carry #

Changed says that something changed. It does not say what it changed to.

Carrying the new value would make this a second, unsolicited path by which a source could assert identity — one arriving outside any conversation, with no key to check it against, and no logon in progress to refuse. An authority that believed it would have accepted an identity assertion it never asked for.

The authority discards what it holds and asks again through Query, where every scope rule in §2.18 to §2.20 applies.

2.18 Identity Confinement

Peios / Peios technical reference / PSPU / Principal Source Interface

A source may assert only principals within its declared domain (§2.10). No configuration lifts this.

An authority MUST refuse an Assertion whose user_sid does not lie within the domain the source registered for, and MUST terminate the logon. It MUST apply the same test to a QueryResult (§2.15) and to the sid of a Changed (§2.17).

2.18.1 Containment #

A principal SID lies within a domain when it is the domain's SID plus exactly one relative identifier.

Exactly one, deliberately. S-1-5-21-A-B-C-1000-1 is not a principal of S-1-5-21-A-B-C, and admitting it would let a source that owns one domain mint names in a nested namespace nobody agreed it owned. A domain SID is likewise not a principal of itself.

2.18.2 Why nothing lifts it #

A source that could assert identities outside its domain could hand out another authority's principals to anyone who satisfied its credential check.

The concrete case: a local source holds no domain credential. If it were unconfined, it could produce a domain administrator's identity for anyone who knew a local password. The domain's own authority would never be consulted and would have no way to know.

Confinement keeps a compromised source at "authority over its own domain" — which is what it already was — rather than "authority over everyone".

This is why identity scope and membership scope (§2.19) are separate settings rather than one. They are different questions, and only one of them has a legitimate exception.

2.19 Membership Scope

Peios / Peios technical reference / PSPU / Principal Source Interface

Group membership is the question with a legitimate exception, and it needs one.

2.19.1 The default #

By default, an authority MUST refuse an Assertion carrying a group outside the domain of the principal being asserted.

A directory vouches for its own users and its own groups, and nothing else. Without this rule, a directory-backed source could declare its users members of BUILTIN\Administrators — making a remote authority the arbiter of who administers this machine.

The test is relative: are the group and the principal in the same domain? It needs no configuration and no knowledge of which domain belongs to whom, which is why membership scope is enforceable before anything else about scope is settled.

2.19.2 The exception #

An authority SHOULD allow a source to be configured as permitted to assert memberships outside the asserted principal's domain.

A local source needs it. Local group membership of any principal is a local decision: "CORP\Domain Admins is in BUILTIN\Administrators" is a record this machine keeps, not something a domain controller asserts at it. Without the exception, a local source could not express the one thing it is most authoritative about.

An authority MAY grant this to more than one source. Nothing about it is exclusive.

2.19.3 Memberships only, never identity #

A source holding this permission remains fully confined on identity (§2.18).

The two are separate because the risks are not symmetric. A source asserting a foreign membership is making a claim about what a principal may do on this machine, which is a local matter and is the local source's business. A source asserting a foreign identity is claiming to be the authority for somebody else's principal, which is never anyone's business but that authority's.

Note

Specifying the permission narrowly — memberships only — from the outset is what allowed identity confinement to be switched on later without changing what the setting means. A permission defined as "this source is trusted" would have had to be redefined, and every existing configuration reinterpreted, the day identity scope arrived.

2.19.4 The primary group is a membership #

primary_group (§2.13) is subject to this section exactly as a listed group is, and an authority MUST apply the test to it.

It would otherwise be a way round: the authority is required to place the primary group on the token whether or not the source listed it, so a source that named BUILTIN\Administrators there and nowhere else would obtain a membership the group array denies it.

An authority that adds an unlisted primary_group to the membership set MUST do so before applying this section, not after. Adding it afterwards reintroduces exactly the route the rule closes.

This binds a primary group the source asserted. A default the authority substituted for an empty field is not the source's claim and MUST NOT be tested against the source's scope (§2.13); an authority that tested its own default would refuse every logon from a source that simply left the field empty.

2.19.5 Claims are the same question, unanswered #

A claim (§2.13) is a trusted input to conditional ACE evaluation, so asserting one can produce a grant just as asserting a membership can. Which claim names a source may assert is therefore the same shape of control as this section — and it is not yet specified.

The gap is stated rather than papered over. An authority federating to a source it does not fully trust should consider claims as it considers foreign memberships, and a future revision is expected to define the control here.

Note

Active Directory splits this in a way worth borrowing: claim type definitions are forest-level configuration while claim values are per-user attributes. The equivalent split — the machine decides which claims exist, the source supplies values for them — is the obvious shape for the control, and is why this is a gap in configuration rather than a flaw in the message format.

2.20 Numeric Scope

Peios / Peios technical reference / PSPU / Principal Source Interface

A source's POSIX identifiers are relative. The authority assigns the range and applies the base; a source is told its range but MUST NOT act on it.

Every unix_id in an Assertion — the principal's and each group's — and every identifier in a QueryResult is an offset within a range the authority assigned that source. The authority adds the base before the number reaches a token or a caller.

This is the numeric counterpart of identity confinement (§2.18). That section stops a source naming principals outside its namespace; this one stops it numbering them outside its namespace.

2.20.1 The range #

An authority MUST assign each source a range, as a base and a count, spanning [base, base + count).

Note

Mainline configures both per source in the registry, as UnixIDBase and UnixIDCount alongside the domain pin (§2.10). How the assignment is expressed is the authority's own design; what it must satisfy is this section.

An authority MUST reserve a band below every source's base for identifiers of its own — well-known SIDs, service SIDs, confinement SIDs — none of which come from any directory. The band has to be generous, because the last of those categories has no bound.

2.20.2 Rebasing #

Given a relative identifier r from a source with range (base, count), the authority computes base + r, and MUST refuse to produce a number at all when:

  • r is 0. Zero is not an identifier; it is how a source says it has no number for something. It MUST NOT become base.
  • r is at or past count. The source has reached outside the range it was given.

A number the authority declines to produce projects as unmapped, which the Peios Kernel TRM §3.10.1 defines.

2.20.3 Refuse, never clamp #

An out-of-range identifier MUST be refused. It MUST NOT be clamped to the top of the range, and it MUST NOT be reduced modulo the count.

Both alternatives look like graceful degradation and are worse than a refusal:

  • Clamping puts two principals on one number, so a filesystem cannot tell them apart.
  • Wrapping lands inside somebody else's range, so one source's principal projects as another source's.

The range is a boundary, not an offset, and the count is what makes it one.

2.20.4 Two numbers a source can never reach #

Because the base sits above the reserved band and a relative identifier cannot escape the count:

  1. uid 0. It belongs to the authority's own table and is not reachable by adding a base to anything a source can send.
  2. Another source's numbers. Whatever a source asserts, arithmetic confines it to its own range.

Neither depends on the source behaving. They hold because of what the source is able to express.

2.20.5 Well-known SIDs are not the source's to number #

An authority MUST use its own identifier for any SID in its reserved band, and MUST ignore whatever unix_id a source sent alongside it.

A source asserting BUILTIN\Administrators is stating a membership. It is not claiming authority over what that group projects to, and honouring a relative identifier there would place a well-known group inside that source's range — where a second source could number something else identically.

2.20.6 The authority tells the source its range #

A source is told its base and count at registration (§2.8). This is informational and exists so an administration tool can show an operator the identifier a principal will really project to, rather than the relative number on disk.

A source MUST NOT apply the base to what it asserts. It is told the range so it can explain itself, not so it can do the arithmetic. A source that applied its own base would have it applied twice.

Note

Keeping the base out of the source's stored records is what makes rebasing a configuration change rather than a data migration. An administrator moving a source's range edits two configuration values; nothing the source persists has to be rewritten, because nothing it persists was ever absolute.

Disclosure costs nothing that matters. What confines a source is not ignorance of the base but the authority's refusal to accept a relative identifier at or past the count — a check the authority performs on every number it rebases, whatever the source knows.

2.20.7 Uniqueness within a source #

SIDs are one namespace; POSIX user and group identifiers are two. A source MUST therefore allocate from a single counter across every kind of object it holds, so that a number issued to a principal is never issued again to a group.

An authority cannot check this — it sees one assertion at a time — so it is stated as an obligation on the source (§2.22) rather than as something enforced.

Note

The simplest way to satisfy it is to make an object's identifier its relative identifier within the domain, which is already unique across principals and groups. That has the incidental benefit that one object has one number rather than two unrelated ones.

2.21 Credential Change

Peios / Peios technical reference / PSPU / Principal Source Interface

A PGSS Logon credential change (PGSS §2.20) reaches the source that holds the principal as a conversation of its own kind. It opens with ChangeCredential, runs the relayed interrogation of §2.12, and ends in CredentialChanged or Refusal.

  authority                              source
    |------------ ChangeCredential ---------->|   conversation N
    |<----------- CredentialRequest ----------|   the current credential
    |------------ CredentialResponse -------->|
    |<----------- CredentialRequest ----------|   the new one
    |------------ CredentialResponse -------->|
    |<------ CredentialChanged | Refusal -----|

It is not an Authenticate that ends differently. A change asserts nobody, so it carries nothing an authority could mint from, and keeping the two apart by message type keeps the path that mints unreachable from this one (§2.4).

2.21.1 ChangeCredential #

msg_type = 0x0007. Authority to source. Opens a conversation.

FieldEncodingLimit
startnested CredentialChangeStart, length-framedPGSS §2.20
principallength-framed bytes (SID)68 bytes

2.21.1.1 start #

The client's CredentialChangeStart, nested whole (§2.7), for the reason Authenticate nests LogonStart (§2.11): it grows on PGSS Logon's schedule, and a field appended there must not displace principal. Its supported_credential_types binds what may be prompted for, exactly as in a logon.

2.21.1.2 principal #

The principal whose credential changes: the user of the client's token, established by the authority from the client's connected socket and never from a message body.

It names both who asked and who is being changed, because PGSS Logon gives a client no way to name anybody else. A source MUST treat it as established fact, MUST NOT change the credential of any other principal in this conversation, and MUST NOT take a principal from anything the client supplies in it.

2.21.1.3 Routing #

An authority MUST send ChangeCredential only to the source authoritative for principal's domain (§2.18), and MUST NOT offer the change to any other source. The rule and its reason are those of §2.11: a credential offered to the wrong source is a credential handed to it.

Where no registered source is authoritative for principal and a configured source is absent, the authority MUST answer the client AuthorityUnavailable rather than refusing the account. The absent source may be the one that holds it, and "this account has nothing to change" would turn an outage into a statement about the account.

An authority MUST NOT send ChangeCredential to a source that did not declare CHANGES_CREDENTIALS (§2.8).

2.21.2 The interrogation #

The rounds are those of §2.12, unchanged: CredentialRequest and CredentialResponse carrying PGSS Logon's bodies, relayed and policed by the authority as they are in a logon. The source decides what to ask for.

A source MUST establish that the principal can present their current credential before changing it (PGSS §2.20), and MUST refuse with AuthenticationFailed where they cannot. The token the authority read principal from is not that proof.

A source that proves the current credential in one round and collects the new one in a later round MUST NOT change the credential if the current credential has itself changed in between. Otherwise an administrator's reset between the two rounds would be overwritten on the strength of the credential it replaced.

2.21.3 CredentialChanged #

msg_type = 0x8008. Source to authority. The only successful outcome of a change conversation. No fields.

A source MUST NOT send it until the new credential is the one it will verify at its next Authenticate for this principal — until the change is durable. The authority relays it to the client as PGSS Logon's CredentialChanged, and PGSS §2.20 forbids reporting a change that a subsequent logon would not see.

2.21.4 Refusal #

A change that does not end in CredentialChanged ends in Refusal (§2.13). A source MUST refuse:

  • with AccountRestricted, a principal it holds no changeable credential for: one that authenticates with no credential, one designated only for service logons, or one that is disabled;
  • with AuthenticationFailed, a current credential that does not verify;
  • with ConversationLimit, a change it gives up on for want of an acceptable new credential.

2.21.5 Terminals out of place #

An authority MUST treat an Assertion in a change conversation, and a CredentialChanged in any other conversation, as a source fault. It MUST end the client's conversation with Internal, MUST NOT mint from the assertion, and MUST NOT report the change.

Abandon (§2.14) applies unchanged. A source discards everything it held for the conversation, including what a completed round established.

2.22 Conformance

Peios / Peios technical reference / PSPU / Principal Source Interface

A conforming implementation MUST satisfy every requirement in this chapter. This section collects them by role.

2.22.1 Authority obligations #

An authority federating over PSI MUST satisfy all of the following.

2.22.1.1 Channel #

  1. Listen; never dial out to a source (§2.3).
  2. Never rely on the socket's descriptor as the access control, and bound unregistered connections and registration time independently of it (§2.6).
  3. Bound registered sources, and conversations per source (§2.6).
  4. Tear down the connection on a framing error or a failed write, rather than attempting resynchronisation (§2.6).

2.22.1.2 Registration #

  1. Require Register on conversation 0 as the first message (§2.8).
  2. Establish the source's identity from the peer's token, never from source_name, and refuse a mismatch rather than correcting it (§2.8, §2.9).
  3. Accept only configured sources, treating an empty configuration as no source may register (§2.9).
  4. Refuse a source that declares no domain (§2.10).
  5. Refuse a domain that is not a well-formed locally-issued domain SID (§2.10).
  6. Refuse a domain another registered source claims (§2.10).
  7. Refuse a source declaring a different domain from the one it declared before (§2.10).
  8. Refuse a source whose declared domain contradicts a configured pin, and treat an unparseable pin as refusing rather than as absent (§2.10).
  9. Send Registered only once the source is routable (§2.8).
  10. Report, where an administrator will see it, a source registering with no identifier range, and a source registering without QUERIES (§2.8).

2.22.1.3 Conversations #

  1. Allocate conversation identifiers, never accepting one from a source, never opening one with an identifier already live, and reserve 0 for registration (§2.7).
  2. Route each logon to exactly one source, resolved before any credential is collected (§2.11).
  3. Never fall back to another source when the owning source is unreachable (§2.11).
  4. Relay the verified originator, taken from the client's socket (§2.11).
  5. Refuse to relay a prompt for a credential type the client did not advertise (§2.12).
  6. Enforce its own round and time limits on the relayed exchange (§2.12).
  7. Send Abandon for any conversation that will not reach a terminal state (§2.14).

2.22.1.4 Assertions #

  1. Validate every SID structurally before treating it as identity, including SIDs carried inside claim values (§2.13).
  2. Drop an asserted logon SID, and drop duplicates first-mention-wins, before applying any scope test (§2.13).
  3. Enforce identity confinement, with no configuration lifting it, on an Assertion, on a QueryResult, and on the sid of a Changed (§2.18).
  4. Enforce membership scope, subject only to a per-source permission covering memberships alone, and apply it to a source-asserted primary_group as well as to listed groups — promoting an unlisted one into the membership set before the test (§2.19, §2.13).
  5. Never apply membership scope to a primary_group it substituted itself for an empty field (§2.13, §2.19).
  6. Fail the logon on a malformed group SID, an unusable claim, a canonical_name PGSS §2.15 forbids, or an out-of-range identifier (§2.13).
  7. Place primary_group on the token even when the source did not list it among groups (§2.13).
  8. Perform derivation itself, and never accept privileges, integrity, or a token from a source (§2.4).
  9. Never relay a refusal reason revealing a distinction the source was required not to make (§2.13).

2.22.1.5 Identifiers #

  1. Apply the source's base to every relative identifier it accepts, and never accept one already rebased (§2.20).
  2. Refuse a relative identifier of 0 or one at or past the source's count, rather than clamping or wrapping it (§2.20).
  3. Reserve a band of identifiers below every source's base for its own, and use its own value for any SID within that band regardless of what the source sent (§2.20).

2.22.1.6 Queries #

  1. Never send a message a source did not declare it answers, and never set a field bit gating a capability it did not declare (§2.8).
  2. Never send more keys in one Query than the source's max_batch, nor more than 64 whatever it declared (§2.8).
  3. Resolve an absolute POSIX identifier to a source and a relative identifier itself, and never send an absolute one to a source (§2.15).
  4. Apply identity confinement, membership scope and numeric scope to a QueryResult exactly as to an Assertion, and validate every SID in one structurally — including those of references inside a value (§2.15).
  5. Rebase every identifier in a result, under the rules of §2.20 (§2.15).
  6. Qualify a source's canonical_name itself; never require a source to (§2.15).
  7. Consult no further source on a Refused result, treat only NotFound as leave to continue, and never relay a source's Refused outward as PGSS Logon's Refused outcome (§2.15).
  8. Never record a source it may not ask — one that declared no QUERIES — as having answered NotFound (§2.15).
  9. Relay cursors opaquely, never construct or modify a source's, and never restart an enumeration on a source's behalf (§2.16).
  10. Record a source that declined or could not be reached, and not retry it for the remainder of that enumeration, across pages as well as within one (§2.16).
  11. Never infer from a source declining to enumerate that it holds nothing (§2.16).

2.22.1.7 Caching #

  1. Not cache a source's answers at all unless it declared PUSHES_CHANGES or a non-zero entry_ttl (§2.8, §2.17).
  2. Not hold an answer beyond a declared entry_ttl (§2.8).
  3. Accept Changed at any time after Registered, and never reply to it (§2.17).
  4. Re-read through Query after an invalidation, and never take a new value from Changed (§2.17).
  5. Treat the loss of a source's connection as All for that source (§2.17).

An authority that holds nothing satisfies 45 to 49 trivially.

2.22.1.8 Credential change #

An authority that relays PGSS Logon's credential change (PGSS §2.20) over PSI MUST also satisfy the following. Obligations 19 to 21 bind a change conversation as they bind an authentication.

  1. Take principal from the client's connected socket, never from a message body (§2.21).
  2. Send ChangeCredential only to the source authoritative for principal's domain, never offer the change to another source, and never send it to a source that did not declare CHANGES_CREDENTIALS (§2.21, §2.8).
  3. Answer the client AuthorityUnavailable, rather than refusing the account, where no registered source is authoritative for principal and a configured source is absent (§2.21).
  4. End the client's conversation with Internal on an Assertion in a change conversation or a CredentialChanged in any other, minting nothing and reporting no change (§2.21).

2.22.1.9 Credential enrolment #

An authority that relays PGSS Logon's enrolment (PGSS §2.23) over PSI MUST also satisfy the following. Obligations 19 to 21 and 50 to 53 bind an enrolment conversation as they bind a change, reading EnrollCredential for ChangeCredential.

  1. Never send EnrollCredential to a source that did not declare ENROLLS_CREDENTIALS, and answer the client PermissionDenied where the source holding the principal did not (§2.23, §2.8).
  2. Relay CredentialRejected only from an enrolment conversation, and end any other conversation carrying it with Internal (§2.23, §2.13).

2.22.2 Source obligations #

A principal source MUST satisfy all of the following.

2.22.2.1 Connection #

  1. Connect to the authority; never listen for it (§2.3).
  2. Open with Register on conversation 0, carrying its name and its domain (§2.8).
  3. Send nothing else before receiving Registered (§2.8).
  4. Report itself ready — to an init system or equivalent — only after Registered (§2.3).
  5. Declare the same domain on every registration, for the life of the machine's configuration (§2.10).
  6. Tear down the connection on a framing error or a failed write, on every path including an unsolicited Changed (§2.6, §2.17).

2.22.2.2 Conversations #

  1. Reply on the conversation identifier it was given, and never invent one (§2.7).
  2. Decline to act on a message on a conversation it does not know, and never treat it as opening one — without replying on it, since the identifier may since have been reused (§2.7).
  3. Refuse an Authenticate, ChangeCredential, Query or EnumerateSource arriving on conversation 0 (§2.7).
  4. Bound the conversations it tracks itself, rather than relying on the authority's limit (§2.6).
  5. Refuse beyond that bound with AuthorityUnavailable, rather than dropping silently (§2.11).
  6. Discard conversation state on Abandon, and not reply (§2.14).

2.22.2.3 Answering #

  1. Send exactly one terminal message per conversation: Assertion or Refusal for an authentication, CredentialChanged or Refusal for a credential change (§2.13, §2.21).
  2. Assert only principals within its declared domain (§2.18).
  3. Assert group SIDs and identifiers only, never attributes (§2.13).
  4. Carry the canonical spelling of the principal's name in canonical_name (§2.13), and never assert a name that PGSS §2.15 forbids — validating what it asserts, not only what it creates.
  5. Never distinguish an unknown principal from a bad credential — by denial code, by reason, or by timing (§2.13).

2.22.2.4 Identifiers #

  1. Assert relative identifiers only, and never apply its own base (§2.20).
  2. Allocate from a single counter across every kind of object it holds, so that no number is issued twice (§2.20).
  3. Send 0 for any object it does not number, including every group it does not own (§2.20).
  4. Never issue an identifier at or past the count it was given (§2.20).

2.22.2.5 Queries #

A source declaring no capabilities (§2.8) is exempt from this section entirely.

  1. Declare only capabilities it implements, and answer every message type it declared (§2.8).
  2. Answer every key of a Query, in order, without reordering, merging or omitting — or refuse the conversation whole (§2.15).
  3. Send Found, NotFound or Refused in a result, and never Unavailable or Malformed (§2.15).
  4. Return its own canonical spelling of a name, unqualified (§2.15).
  5. Return relative identifiers in a result, exactly as in an Assertion (§2.15, §2.20).
  6. Answer only for principals within its declared domain, on a query as on a logon (§2.18).
  7. Answer Refused, never an empty Found, wherever it is declining rather than reporting an absence — including a membership it will not expose, a key naming an object it does not hold, and a key of the wrong kind (§2.16).
  8. Refuse a cursor it can no longer honour, rather than restarting or answering from a changed store (§2.16).
  9. Hold no per-cursor state it is unwilling to discard unasked (§2.16).
  10. Size a page against the smaller of PSI's message ceiling and PGSS Logon's, since the authority must re-encode it into the latter (§2.16, §2.A).
  11. Send Changed before the altered answer becomes observable, if it declared PUSHES_CHANGES (§2.17).
  12. Declare a non-zero entry_ttl if it does not push changes and can tolerate its answers being held, and SHOULD declare one even if it does (§2.8, §2.17).

2.22.2.6 Credentials #

  1. Store verifiers that are not usable as credential material — nothing a challenge could be recomputed from (PGSS §2.11).
  2. Erase credential material, and every buffer it was decoded through, before that memory is released (PGSS §2.12).
  3. Never write credential material to a log, audit record, or diagnostic (PGSS §2.12).

2.22.2.7 Logon type restrictions #

  1. State a principal's permitted_logon_types where it holds the property, and send zero where it does not (§2.13).
  2. Never send "everything" for a principal whose restrictions it does not hold — zero is the honest answer, and "everything" asserts that the principal may be used for a credential-free service logon (§2.13, PGSS §2.19).
  3. Never refuse an authentication on the strength of the restrictions it states. A source states; the authority decides (§2.4, §2.13).
  4. Answer LOGON_TYPES from a Query as stored, including "not stated", and never substitute a default of its own — what silence means is the authority's reading, and two components with their own defaults can disagree (§2.15, PGSS §2.16).

2.22.2.8 Credential change #

A source that declares CHANGES_CREDENTIALS MUST also satisfy the following.

  1. Change only the credential of principal, and never take a principal from anything the client supplied (§2.21).
  2. Establish that the principal can present their current credential before changing it, and refuse with AuthenticationFailed where they cannot (§2.21).
  3. Never change a credential on the strength of a current credential that has changed since it was proved (§2.21).
  4. Refuse with AccountRestricted a principal it holds no changeable credential for (§2.21).
  5. Send CredentialChanged only once the new credential is the one its next Authenticate for that principal will verify (§2.21).

2.22.2.9 Credential enrolment #

A source that declares ENROLLS_CREDENTIALS MUST also satisfy the following.

  1. Add or remove credentials only of principal, and never take a principal from anything the client supplied (§2.23).
  2. For both adding and removing, establish that the principal can present a credential they already have before changing anything, refuse with AuthenticationFailed where they cannot, and refuse with AccountRestricted, before asking, a principal it can ask for no such credential (§2.23).
  3. Check the material again when applying the change, refuse material it will not accept with CredentialRejected, and send that code in no other conversation (§2.23).
  4. Send CredentialChanged only once the change is durable, and, where it declares PUSHES_CHANGES, only after Changed (§2.23, §2.17).

2.22.3 What a source is not required to do #

A source is not required to store anything, to be local, to be persistent, or to know what a token is. It answers one question: given this identifier and whatever it chose to ask for, who is this?

Nor is it required to trust the authority beyond the connection. A source that refuses logons on the strength of originator, or declines to answer for principals it holds but does not wish to expose, is conforming.

2.23 Credential Enrolment

Peios / Peios technical reference / PSPU / Principal Source Interface

A PGSS Logon enrolment (PGSS §2.23) reaches the source that holds the principal as a conversation of its own kind. It opens with EnrollCredential, runs the relayed interrogation of §2.12, and ends in CredentialChanged or Refusal.

  authority                              source
    |------------ EnrollCredential ---------->|   conversation N
    |<----------- CredentialRequest ----------|   the current credential
    |------------ CredentialResponse -------->|
    |<------ CredentialChanged | Refusal -----|

It is the sibling of the credential change (§2.21), and every rule that section states binds here unless this section says otherwise: routing to the source authoritative for the principal, AuthorityUnavailable while a configured source is absent, the principal as established fact, the terminals out of place, and Abandon.

2.23.1 EnrollCredential #

msg_type = 0x0008. Authority to source. Opens a conversation.

FieldEncodingLimit
startnested CredentialEnrollStart, length-framedPGSS §2.23
principallength-framed bytes (SID)68 bytes

start is the client's CredentialEnrollStart, nested whole for the reason ChangeCredential nests its start (§2.21). principal is the user of the client's token, established by the authority from the client's socket, exactly as in ChangeCredential. A source MUST NOT add a credential to, or remove one from, any other principal in this conversation.

2.23.2 The capability #

An authority MUST NOT send EnrollCredential to a source that did not declare ENROLLS_CREDENTIALS (§2.8). Where the source that holds the principal did not declare it, the authority MUST answer the client PermissionDenied: for that principal it does not offer enrolment, and PGSS §2.23 gives that answer to an authority that does not.

ENROLLS_CREDENTIALS and CHANGES_CREDENTIALS are independent. A source may change passwords and not enrol keys, or the reverse.

2.23.3 The interrogation #

The source MUST establish that the principal can present a credential they already have, other than the one being enrolled, before it adds or removes anything, for Add and Remove alike, and MUST refuse with AuthenticationFailed where they cannot. The token the authority read principal from is not that proof. A source that cannot ask for such a credential for this principal — the principal has none, or signs in only with the kind being enrolled — MUST refuse with AccountRestricted before asking anything.

The source decides which credential types it enrols and what material it accepts. It SHOULD check the material before asking for proof, MUST check it again when it applies the change, and MUST refuse material it will not accept with CredentialRejected. A source MUST NOT send CredentialRejected in any conversation but this one, and an authority receiving it in any other MUST end the client's conversation with Internal (§2.13).

2.23.4 CredentialChanged #

The successful terminal is CredentialChanged (§2.21), sent only once the change is durable: an added key is one the source's next Authenticate will accept where the principal's policy allows keys, and a removed key is one it will not.

A source that declares PUSHES_CHANGES MUST send Changed (§2.17) before CredentialChanged, as for any other change to what it holds.

Enrolment does not change which credentials the principal may sign in with; that remains the source's administration.

Appendix 2.A Message Reference

Peios / Peios technical reference / PSPU / Principal Source Interface

2.A.1 Messages #

msg_typeMessageDirectionConversationDefined in
0x8001Registersource → authority0§2.8
0x0001Registeredauthority → source0§2.8
0x0002Authenticateauthority → source1+ (opens)§2.11
0x8002CredentialRequestsource → authority1+§2.12
0x0003CredentialResponseauthority → source1+§2.12
0x8003Assertionsource → authority1+ (terminal)§2.13
0x8004Refusalsource → authority1+ (terminal)§2.13
0x0004Abandonauthority → source1+ (terminal)§2.14
0x0005Queryauthority → source1+ (opens)§2.15
0x8005QueryResultsource → authority1+ (terminal)§2.15
0x0006EnumerateSourceauthority → source1+ (opens)§2.16
0x8006EnumerateResultsource → authority1+ (terminal)§2.16
0x8007Changedsource → authority0§2.17
0x0007ChangeCredentialauthority → source1+ (opens)§2.21
0x8008CredentialChangedsource → authority1+ (terminal)§2.21, §2.23
0x0008EnrollCredentialauthority → source1+ (opens)§2.23

The high bit marks a message sent by the source, which is the authority for its own principals (§2.7).

2.A.2 Protocol constants #

ConstantValueDefined in
Socket paththe implementation's choice§2.6
MagicPPSI (50 50 53 49)§2.7
Version1§2.7
Header size20 bytes§2.7
Maximum message size81920 bytes§2.7
Reserved conversation0§2.7

2.A.3 Field limits #

FieldMaximumDefined in
source_name32 bytes§2.8
domain68 bytes§2.8
max_batch64§2.8
originator68 bytes§2.11
principal68 bytes§2.21, §2.23
user_sid68 bytes§2.13
canonical_name256 bytes§2.13
groups128 entries, each SID 68 bytes§2.13
primary_group68 bytes§2.13
claims64 entries§2.13
claim name255 bytes§2.13
claim values64 per claim§2.13
claim string value1024 bytes§2.13
claim octet value1024 bytes§2.13
claim SID value68 bytes§2.13
reason512 bytes§2.13
keys64 entries§2.15
key name256 bytes§2.15
results64 entries§2.15
withheld32 entries§2.15
values32 entries§2.15
entries256 entries§2.16
cursor, next256 bytes§2.16

68 bytes is the largest a SID can be: an eight-byte prelude plus fifteen sub-authorities (§2.7).

A claim name is bounded at 255 bytes of UTF-8 while PCDS §5.9 bounds it at 255 UTF-16 code units. A string's UTF-16 length never exceeds its UTF-8 byte length, so the byte bound is the stricter of the two and satisfies PCDS without transcoding to find out.

The claim limits are otherwise tighter than PCDS §5.9 permits — it allows 1024 values per claim. These bound the work an authority does decoding a message it has not yet decided to believe, and nothing needs a thousand-valued claim from a principal source.

Fields inside a nested LogonStart, CredentialChangeStart, CredentialRequest, CredentialResponse or profile keep PGSS Logon's limits (PGSS §2.A).

2.A.4 The ceiling that actually binds a page #

None of the entry counts above is the constraint on how much a source may return. An entry that fits a PSI message need not fit the PGSS Logon message the authority must re-encode it into: this chapter's ceiling is 81920 bytes and PGSS Logon's is 65536, and a QueryResult or EnumerateResult entry travels outward inside the smaller one.

A source MUST therefore bound a reply by the smaller of the two ceilings, not by this one, and MUST page rather than fill a PSI message it knows an authority cannot forward. A page that fits here and not there is a page nobody can deliver, and the entry-count bounds do not prevent one — 256 entries of a few hundred bytes each exceeds both.

The margin an implementation leaves for the authority's own framing is its own choice; leaving none is a defect.

Appendix 2.B Enumerations

Peios / Peios technical reference / PSPU / Principal Source Interface

Values PSI defines for itself. Everything else it carries is PGSS Logon's — see PGSS §2.B.

Adding a value to any enumeration here is a breaking change requiring a version bump (§2.7). The two exceptions are the capability bitmask below, and PGSS Logon's field bitmask, which PSI carries unchanged and which may gain bits without one.

2.B.1 Key types #

Carried in a Query key entry's key_type (§2.15) as a u8.

ValueNameKey is in
0noneThe key entry is absent
1Namename
2Sidsid
3RelativeIdrelative_id

Zero is not a key. It is how an EnumerateSource encodes an empty of (§2.16), which is the only place it may appear; an encoder MUST NOT send it in a Query key and a decoder MUST reject one that arrives there.

PGSS Logon's key types (PGSS §2.B) share the first two values and differ in the third, where it carries an absolute POSIX identifier. The values are not interchangeable and the tables are deliberately separate: 3 means a rebased number on one side of the authority and a relative one on the other, which is the whole of §2.20 expressed as a number.

2.B.2 Result outcomes #

Carried in a result entry's outcome (§2.15) and in EnumerateResult.outcome (§2.16) as a u8.

The values are PGSS Logon's (PGSS §2.B). A source may send only these three:

ValueName
1Found
2NotFound
4Refused

Unavailable (3) and Malformed (5) are the authority's to produce and MUST NOT be sent by a source — see §2.15. A decoder MUST reject either arriving from a source, rather than leaving the check to a caller.

2.B.3 Change scopes #

Carried in Changed.scope (§2.17) as a u8.

ValueName
1All
2Object

2.B.4 Capabilities #

Carried in Register.capabilities (§2.8) as a u32 bitmask.

BitName
0QUERIES
1ENUMERATES
2MEMBERS
3PUSHES_CHANGES
4CHANGES_CREDENTIALS
5ENROLLS_CREDENTIALS

Unlike the enumerations above, a bit MAY be added here without a version bump. A source that does not set a bit has not declared the capability, and an authority MUST NOT send a message the source did not declare it answers (§2.8) — so an authority that predates a bit simply never uses it, and a source that predates one never sets it. Both are the safe reading.

MEMBERS gates a field rather than a message: an authority MUST NOT set the MEMBERS field bit of a Query (§2.15) against a source that did not declare it.

2.B.5 Claim value types and flags #

A claim's value_type and flags (§2.13) are PCDS §5.9's, and are not restated here.

They are nonetheless closed on this interface: a decoder MUST reject a value type or a flag bit it does not recognise, rather than carrying it through to an authority that will put it on a token. Adding one is therefore a breaking change to PSI by the rule above, even though the values themselves belong to PCDS.

Appendix 2.C What Is Shared with PGSS Logon

Peios / Peios technical reference / PSPU / Principal Source Interface

PSI is a superset of PGSS Logon (§2.5). This appendix consolidates exactly what is shared, what is added, and what differs — as a checklist for an implementer building both, and as the list to re-examine whenever either specification changes.

2.C.1 Shared unchanged #

ElementPGSSNote
LogonStart body§2.7Nested whole inside Authenticate, never inlined (§2.7)
CredentialRequest body§2.8Byte-for-byte identical
CredentialResponse body§2.8Byte-for-byte identical
profile body§2.9Nested inside Assertion, relayed onward unchanged (§2.13)
Denial codes§2.BReused by Refusal (§2.13)
Lookup result body, present onward§2.16Nested inside a QueryResult entry (§2.15)
Field bitmask§2.BCarried unchanged by Query (§2.15)
Object kinds§2.BCarried unchanged by a key entry (§2.15)
Lookup outcomes§2.BA source may send three of the five (§2.B)
Header layout, first 12 bytes§2.6Same fields at the same offsets
Byte order, string encoding, length framing§2.6See §2.7
Extensibility rules§2.6Append-only; new enum value is breaking
Credential-handling obligations§2.12Bind sources too (§2.22)
Name rules§2.15Bind what a source asserts (§2.13)

An implementation that reimplements any of these rather than sharing one definition has taken on the job of keeping two copies in step. The sharing is the point: a translation layer between two byte-identical formats is a place for them to drift.

2.C.2 Added by PSI #

ElementDefined in
conversation header field§2.7
Register / Registered§2.8
Authenticate, wrapping LogonStart plus originator§2.11
Assertion§2.13
Abandon§2.14
Domain claim and its checks§2.10
POSIX identifiers, and the ranges that confine them§2.13, §2.20
Claims carried from a source§2.13
Query / QueryResult, and batching§2.15
EnumerateSource / EnumerateResult, and cursors§2.16
Changed, and the cache contract§2.17
Source capabilities, TTL and batch limit§2.8
Relative key types, where PGSS Logon's are absolute§2.15, §2.B
Identity, membership and numeric scope§2.18 to §2.20

2.C.3 Differs #

PGSS LogonPSI
MagicPGSLPPSI
Header12 bytes20 bytes
Maximum message6553681920
Conversations per connectiononemany
Connection lifetimeone logonthe source's lifetime
High bit of msg_typeauthority → clientsource → authority
Success terminalAccessGranted + token fdAssertion — no session, no token
Socket pathnormativethe implementation's choice
Key type 3absolute POSIX identifierrelative identifier

The success terminal is what makes minting structurally impossible for a source (§2.4); the socket path is normative in PGSS because it is a conformance bar and not here because PSI is not one (§2.1).

The message ceiling is the row most likely to catch an implementer out, because the larger number is the one that does not bind a reply — see §2.A.

2.C.4 When either specification changes #

A field appended to LogonStart, CredentialRequest, CredentialResponse or profile in PGSS appears here automatically, because the bodies are shared. That is the intended behaviour and needs no change to this chapter.

The profile is the one shared body that travels in the opposite direction to the others: the interrogation bodies pass from the authority outward to the client, while the profile originates at the source and is relayed outward through the authority. It is shared for the same reason regardless — one definition, so the value a source states and the value a client reads cannot drift apart.

A new Denial value, a new CredentialType, or any change to the header's first twelve bytes is a breaking change to both and requires a coordinated version bump. An implementer maintaining both MUST NOT bump one alone.

The one exception is a denial added under PGSS §2.6's rule for codes that only newer messages draw. NoSuchSession is one: it answers only requests a source is never party to, a source never sends it (§2.13), and so no PSI peer of either age meets it.

Appendix 2.D Prior Art

Peios / Peios technical reference / PSPU / Principal Source Interface

2.D.1 What this exists to avoid #

The design is shaped more by rejected approaches than adopted ones.

LSA authentication packages. Windows loads authentication packages as DLLs into the LSA process. A defect in any package is a defect in the most privileged process on the system, and the packages are exactly the components most likely to parse hostile input. PSI puts that boundary at a process, permanently: there is no in-process extension point and no message that could create one.

PAM modules. The same objection, plus stacking — offering a credential to each module in turn until one accepts, which hands every module the credentials of every other module's users. PSI resolves which source answers before any credential is collected (§2.11).

NSS. Name service switch modules answer "who is this?" as a library call in whatever process asked, with no boundary at all. PSI's answer is a message from a process that was separately identified.

2.D.2 What is adopted #

RSI's shape, specified in PSPK. A long-lived connection, sources that dial in and register, multiplexed requests tagged with an identifier, and the authority tearing down a connection it cannot parse. PSI is recognisably the same family, and deliberately so — an implementer who has written a registry source will find little surprising here.

The differences are worth naming, because they follow from what is being federated. A registry source is trusted with the correctness of a subtree and the kernel validates its structure; a principal source is trusted with identity, so the checks it faces are about scope — which principals, which memberships, which numbers — rather than about well-formedness alone. And the kernel assigns a registry source no numeric range, because there is nothing to project.

PGSS Logon's interrogation, wholesale. Rather than inventing a parallel vocabulary for prompts and answers, PSI relays PGSS Logon's messages with identical bodies (§2.5). The gain is not brevity but correctness: there is no translation layer to be lossy, and a source's prompt reaches the client exactly as written.

2.D.3 Design influences #

Sources connect inward. The authority never dials out. See §2.3 — this is the single most consequential shape decision in the chapter.

Assertion, not minting. The success terminal deliberately cannot express a session or a token (§2.4). The separation is structural rather than a rule an implementer must remember.

Domains claimed, not assumed. A source states what it is authoritative for and the authority confines it to that (§2.10, §2.18). The alternative — an authority that trusts whatever a source says about anyone — makes every source as dangerous as the most dangerous one.

Relative numbers. POSIX identifiers are the one thing a source states that has no namespace of its own to be confined by, so the range supplies one (§2.20). Nothing else in the protocol needed inventing; a SID already carries its domain.

3.1 Scope and Roles

Peios / Peios technical reference / PSPU / Observability Interfaces

This chapter specifies the observability interfaces: the three channels by which the programs on a Peios system deposit logs and metrics with an observability service, and by which anything on the system asks that service what it holds.

There are three interfaces and they are specified together because they are one contract from the service's side and because two of the three share their encoding, their validation posture and their loss model:

  • the Log Ingestion Interface, on which a producer submits log records (§3.6 to §3.8)
  • the Metric Ingestion Interface, on which a producer submits metric samples (§3.9 to §3.13)
  • the Query Interface, on which a client asks for stored events, logs and metrics and receives records (§3.14 to §3.28)

Three roles participate.

The collector is the process that accepts ingestion on the two datagram channels, serves the query channel, and holds the data in between. There is one collector. It is the party being asked, on all three interfaces — which is why the obligations in this chapter fall mostly on it, and why the producer and client roles are so thin.

A producer is a process that submits metric samples. Its KACS token is conveyed with every datagram, and the collector verifies that it may publish each metric name (§3.9). Service output reaches the log channel through the service manager instead: the service manager authenticates the service origin by the pipe it owns, then acts as the sole log broker (§3.6).

A client is a process that issues a query and reads the result. A client's identity, unlike a producer's, is established by the collector and determines what it may see (§3.28).

One program is commonly all three at once.

This chapter covers:

  • the shape of the three channels and why two are datagram and one is a stream (§3.3)
  • the loss model, which is the load-bearing decision of the whole ingestion design (§3.4)
  • encoding, timestamps and the timestamp domain (§3.5)
  • the log record, its fields, and exactly which malformations cost the record and which are merely ignored (§3.6 to §3.8)
  • the metric data model, the three metric types, the sample record, and what makes two samples the same time series (§3.9 to §3.13)
  • the query channel, its framing, and the four response messages (§3.14 to §3.17)
  • the query language: its shape, its lexis, its operators, its ordering and grouping semantics, and the three modes (§3.18 to §3.25)
  • cross-type filtering and streaming (§3.26, §3.27)
  • what a client is and is not told about data it may not read (§3.28)
  • how these interfaces may be extended (§3.29)
  • the obligations binding on each role (§3.30)

This chapter does not cover:

  • Event emission. Events reach a collector through KMES, not through any interface here; the consumer side of that is specified in PSPK, and emission is a kernel interface offered to privileged callers.
  • Event type vocabulary and payload schemas, which belong to whichever subsystem emits the event.
  • How a collector stores, indexes, retains or accelerates anything — its own design. The mainline collector's is described in the eventd TRMP.
  • Which principals receive publication rights for individual metric names, and how those rights are administered.
  • Administering a collector's contents.

The third of those is the point of the whole document. A collector is handed records and asked questions; how it gets from one to the other is exactly what different collectors exist to do differently.

3.1.1 These interfaces are not a conformance requirement #

A system that offers none of these is still Peios. Observability is not in the definition of the platform, and a system that ships a different collector, or none, conforms exactly as well.

They are specified because they are public. Every service has its output brokered into logs, every collection agent is a metric producer, and every dashboard, alerting tool and command-line viewer is a query client. All three of those are third-party positions, and all three need a contract that stays put.

Note

The Query Interface reaches events as well as logs and metrics, even though events do not arrive over any interface here. That asymmetry is deliberate: PSPK's ring-buffer interface is the raw transport, requires SeSecurityPrivilege, and applies no per-record access control. The query interface is how everything else reads events, and it is the only way to read them after the ring buffer has moved on.

3.2 Terminology

Peios / Peios technical reference / PSPU / Observability Interfaces

Terms defined in PSPK for the KMES event stream — event, header, payload, stamp, sequence number, origin class — are used here with the same meaning and are not redefined. Terms defined in PCDS — GUID, SID, Security Descriptor, ACL, ACE — likewise.

The following terms are specific to this chapter.

Collector: the process that accepts log and metric ingestion and serves queries. The role, not the program: the mainline collector is eventd, and this chapter never requires that it be.

Producer: a process that submits log records or metric samples.

Client: a process that issues a query.

Log record: one line of output from a program, with the light metadata of §3.7 attached. A log record is text; the collector does not parse it.

Metric sample: one measurement of one quantity at one moment, belonging to a time series (§3.13).

Time series: the sequence of samples sharing a name, a label set, and — for histograms — a set of bucket boundaries. Identity is defined in §3.13.

Boot ID: a GUID identifying one boot of the system. Every record a collector stores carries one, so that records from different boots are never interleaved and per-CPU event sequence numbers, which restart each boot, remain unambiguous. It is assigned outside this interface and is visible to a client only as a queryable field.

Datagram: one message on an ingestion channel, carrying either one record or a batch of them (§3.7, §3.11).

Effective query range: the half-open interval a query examines, [SINCE, UNTIL), with the bounds resolved as §3.19 defines.

Concrete identifier: the event type, log origin or metric name that a stored record actually carries — as distinct from the pattern a query or a Security Descriptor uses to match one. Access control resolves per concrete identifier (§3.28).

3.3 Three Channels

Peios / Peios technical reference / PSPU / Observability Interfaces

A collector listens on three AF_UNIX sockets. Two carry data inward, one carries it out.

ChannelSocket typeDirectionSection
Log ingestionSOCK_DGRAMproducer to collector§3.6
Metric ingestionSOCK_DGRAMproducer to collector§3.9
QuerySOCK_STREAMrequest and response§3.14

The pathnames are configuration and this chapter does not fix them. A collector MUST serve each interface on a distinct socket; it MUST NOT multiplex two of them onto one.

3.3.1 Why ingestion is datagram #

Each submission is an independent message. A datagram either arrives whole or does not arrive, so there is no framing to get wrong, no length prefix to parse, no partial read to reassemble, and no connection state to keep for a producer that submits one line an hour. The record boundary is the datagram boundary.

The property that matters more is that a datagram socket cannot exert backpressure. When the receive queue is full the kernel discards the datagram and the sender proceeds. That is the behaviour §3.4 requires, and choosing a stream socket would make it unachievable: a full stream buffer blocks the writer, which is precisely the outcome this design forbids.

3.3.2 Why the query channel is a stream #

A query result is arbitrarily large, is delivered in several messages, and must not be silently truncated — the opposite requirements. It also needs a caller identity, and a peer token can be obtained from a connected stream socket. Both push the same way.

3.3.3 Separation #

The three channels are separated for the same two reasons.

The first is admission. Each listening socket has its own receive queue. Log volume is orders of magnitude above query volume on any normal system, and a burst of either must not delay the other. Three sockets means three populations of caller that cannot starve one another, whatever load any of them is under.

The second is access control. The service manager alone writes the log channel, while service processes write the metric channel under their own conveyed identities; the set that may read either is different again. Those want different Security Descriptors, and a descriptor is a property of a socket.

The separation is not for isolation. One collector serves all three, so a defect or a hang in any of them reaches the others regardless, and this chapter does not pretend otherwise.

3.3.4 Protecting the channels #

A collector MUST protect each socket with a Security Descriptor.

The log socket descriptor MUST deny FILE_WRITE_DATA to the Service logon group before allowing SYSTEM. The service manager runs as SYSTEM without the Service group; every service it launches, including a SYSTEM service, carries that group. The ordered deny therefore makes the service manager the log broker without making every SYSTEM service one (§3.6).

The metric socket descriptor is coarse admission only. Every metric datagram also carries a KACS sender token and every metric name is authorized for EVENTD_PUBLISH (§3.9). A collector SHOULD let every authenticated caller send by default, so that a publish grant to anyone can be used; an operator MAY narrow it.

The query socket descriptor MUST let every authenticated caller connect. What a caller may read is decided per identifier against the read policy (§3.28), so a socket that admitted fewer callers would only make a policy grant to anyone else impossible to use.

A collector MUST NOT rely on the socket's POSIX mode bits for this. On Peios an access decision is routed through the object's Security Descriptor, not through mode bits, so a mode set on a socket pathname does not restrict anything; and an inode created without a descriptor is denied to every caller, so a collector that binds a socket into a directory carrying no inheritable ACEs produces a socket nothing can reach. A collector MUST establish the descriptor on each socket before it begins accepting on it.

3.4 Loss and Backpressure

Peios / Peios technical reference / PSPU / Observability Interfaces

The single decision that shapes both ingestion interfaces is this: a producer is never slowed down, and never told that a record was lost.

3.4.1 The obligation #

A collector MUST NOT exert backpressure on a producer. A producer MUST NOT stall, block, retry or otherwise change its behaviour because a collector is slow, busy, or absent.

The consequence is accepted openly. When a collector cannot drain an ingestion socket as fast as producers fill it, the kernel discards datagrams. Neither party is notified. A collector MUST NOT report the loss to the producer, because there is no reply message on a datagram channel and adding one would reintroduce the coupling this rule exists to prevent.

3.4.2 Why loss is acceptable here #

A lost log line is an inconvenience. A lost metric sample is a visible gap in a chart. Neither is a failure of the system, and neither is worth the cost of the alternative — which is either blocking the producer or buffering without bound, and the second is only the first with a delay.

Events are the counter-example, and the reason the boundary between events and logs matters. An event may be a security audit record whose absence is itself the finding, so events do not travel on either interface in this chapter: they travel through KMES, where loss is detected, bounded and recorded. A program with data that must not be lost emits an event; a program with output for a human to read writes a log.

Note

This is the practical test for producers deciding where output belongs. If you would want to know that a record went missing, it is not a log and not a metric.

3.4.3 What a collector must not do about it #

A collector MUST NOT emit an event, write a log entry, or perform any other work proportional to the volume of malformed or unwanted input it receives.

Ingestion input is untrusted even though its admission is authenticated (§3.3). A collector that reacted to bad input — by logging it, by counting it in a way a client can observe, or by emitting a diagnostic event — would hand an admitted process an amplification primitive: a cheap malformed datagram producing an expensive durable record. Silence is the defence. In-memory diagnostic counters and rate-limited standard error output are permitted because they do not grow with durable input.

The rule binds only on responses to input. A collector MAY record its own internal conditions, and the mainline collector records several, as synthetic events and as health metrics (eventd TRMP §2.6, §5.7).

3.4.4 Ordering and duplication #

A collector MUST NOT assume that datagrams arrive in the order they were sent, and MUST NOT reorder or deduplicate the records inside one. A producer MUST NOT assume that submitting two datagrams in order causes them to be stored in that order; the timestamp field (§3.7, §3.11) is the only ordering a producer controls.

Records are not deduplicated. A producer that submits the same record twice has produced two records.

3.5 Encoding and Time

Peios / Peios technical reference / PSPU / Observability Interfaces

3.5.1 MessagePack #

Every structured value on all three interfaces — log records, metric samples, query requests, query responses — is encoded as MessagePack. Strings are UTF-8.

The choice is inherited rather than made here: KMES event payloads are a single MessagePack value, so a collector already carries a decoder and a query result can carry an event payload outward without re-encoding it.

A decoder MUST accept any valid MessagePack encoding of a value it is given. In particular a producer MAY use any length-prefix width that can represent the value, and a collector MUST NOT require the shortest.

3.5.2 Canonical MessagePack #

Where this chapter requires a canonical encoding, the value MUST be encoded as follows:

  • Nil and booleans use the fixed singleton encodings.
  • Integers use the shortest encoding that preserves signedness: non-negative values use positive fixint, uint8, uint16, uint32 or uint64; negative values use negative fixint, int8, int16, int32 or int64.
  • Floats are encoded as float64. Finite values use their IEEE-754 binary64 representation; the infinities use the normal binary64 encodings; any NaN is encoded as the single quiet NaN bit pattern 0x7ff8000000000000.
  • Strings, binary values, arrays and maps use the shortest length-prefix form capable of representing the length.
  • Arrays encode each element recursively, in order.
  • Maps encode keys and values recursively, with entries sorted by the canonical encoded key bytes; ties are broken by the canonical encoded value bytes.

Canonical encoding exists so that two values that are equal are also byte-identical, which is what makes them comparable and orderable without decoding. It is required in exactly two places: histogram sample storage, where it makes a sample map a stable value (§3.11), and array comparison in query ordering and grouping (§3.21).

It does not constrain what a producer sends. Ingestion accepts any valid encoding.

3.5.3 Timestamps #

A timestamp is wall-clock time in nanoseconds since the Unix epoch, UTC, as a signed 64-bit value.

The timestamp domain is 0 to 9223372036854775807 inclusive. A value outside it is invalid wherever it appears: as a producer-supplied timestamp (§3.8, §3.12), as a query time literal (§3.19), or as a value in a result record.

The domain has no negative half. A collector MUST reject a negative timestamp rather than storing a time before 1970, and a query whose time arithmetic lands below zero — SINCE 100000d ago, for example — MUST produce an error rather than clamping.

Note

The upper bound is i64::MAX nanoseconds, which is the year 2262. The domain is stated as a closed range rather than "whatever fits" because both parties must agree on where the edge is: a producer that clamps and a collector that rejects would disagree about the same sample.

Wall-clock time is not monotonic. A collector MUST store the timestamp it is given or derives without correcting it, and MUST NOT assume that timestamps within one time series increase (§3.13). A clock step backwards produces records that are out of order with respect to their arrival, and every ordering rule in this chapter is defined to remain total and deterministic when that happens.

3.6 The Log Channel

Peios / Peios technical reference / PSPU / Observability Interfaces

A collector MUST expose an AF_UNIX SOCK_DGRAM socket for log ingestion, protected by a Security Descriptor as §3.3 requires.

3.6.1 The datagram ceiling #

A collector declares a maximum accepted datagram size, the log datagram ceiling. The portable ceiling is 262144 bytes. Every collector MUST accept a whole datagram up to that size, and every portable producer MUST bound its encoded datagrams to that size without discovering collector configuration. A collector MAY accept more, but a producer MUST NOT depend on that when implementing this interface.

A collector MUST receive log datagrams into a buffer of at least its declared ceiling, and MUST discard a datagram the kernel reports as truncated rather than storing the prefix that fitted.

A producer MUST NOT send a log datagram larger than the ceiling. One that does is discarded whole, taking every record in it, and the producer is not told (§3.4).

A sending socket's own buffer can be smaller than the ceiling. A producer that may send a datagram longer than its socket's default send capacity SHOULD first raise SO_SNDBUF enough to accommodate it.

On Linux, a Unix datagram longer than the effective SO_SNDBUF returned by getsockopt minus 32 bytes is refused with EMSGSIZE. Linux doubles the value requested with setsockopt(SO_SNDBUF) for bookkeeping; the effective value must therefore be at least the datagram's length plus 32 bytes. An ordinary unprivileged request is capped at net.core.wmem_max before that doubling. The initial buffer comes from net.core.wmem_default; neither sysctl has a universal value.

For example, with both sysctls set to 212992 bytes, the initial buffer allows datagrams of at most 212960 bytes, below the portable ceiling. Requesting 212992 bytes gives an effective buffer of 425984 bytes and a datagram limit of 425952 bytes. A larger datagram needs a higher wmem_max for an ordinary unprivileged sender. A collector's configured ceiling does not itself enlarge the sender's buffer.

Note

§3.A gives the mainline value and upward-adjustable range for this bound and every other in this chapter.

No capability exchange is needed. The 262144-byte floor is part of the wire contract rather than a tunable assumption shared out of band. Raising a collector's ceiling is a local extension; lowering it below the portable ceiling is non-conforming.

3.6.2 The receive queue is the buffer #

A collector MAY enlarge the socket receive queue, to at most four times the datagram ceiling. It MUST NOT buffer beyond it.

That queue is the only cushion between a producer and the collector's storage. While a collector is committing a batch it is not draining the socket, and datagrams arriving in that window occupy the queue; when the queue fills, they are discarded. This is the designed degradation (§3.4), not a failure to be tuned away — a larger buffer moves the threshold without changing what happens at it, and an unbounded one converts data loss into memory exhaustion.

3.6.3 The service-manager broker #

Every service produces output, including programs that know nothing about the collector, but service processes do not write this socket. The service manager holds each service's standard output and standard error at fork, determines the origin from the pipe it read, and forwards the resulting records (peinit TRM).

The log socket MUST implement the deny-Service, allow-SYSTEM descriptor in §3.3. A collector MUST accept log origins from this broker without a per-record KACS token or AccessCheck. A service can choose the bytes it writes to its pipe but cannot choose another service's origin.

Direct log submission is not part of this interface. A program that wants structured, authenticated observability data emits an event or a metric; it does not bypass the service manager to assert a log origin.

3.7 Log Records

Peios / Peios technical reference / PSPU / Observability Interfaces

A log datagram carries either one record, encoded as a MessagePack map, or several, encoded as a MessagePack array of maps. A collector MUST accept both forms. A producer MAY use either at any time; there is no mode and no negotiation.

3.7.1 Fields #

FieldTypeRequiredMeaning
originstringyesNon-empty name of the program that produced the line.
is_errorboolyesTrue if the line came from standard error, or the producer marked it an error. False otherwise.
messagestringyesThe log text — one line of output. MAY be empty, which is a blank line.
timestampintegernoWhen the line was produced, in the timestamp domain (§3.5). Absent means the collector uses its own clock at receipt.
job_idbinary, 16 bytesnoA GUID in PCDS binary layout correlating this line to one execution of one program.

A collector MUST ignore fields it does not recognise (§3.29).

3.7.2 origin #

origin is what the producer says it is. A collector MUST NOT verify it, because it has no way to: the channel is a datagram socket and carries no peer identity (§3.4). Two producers MAY use the same origin, and one producer MAY use several.

An origin is nonetheless the unit that read access is granted on (§3.28), and a collector matches it against patterns using dot-delimited prefix semantics: the pattern svc matches the origin svc and any origin beginning svc., and matches neither svc_daemon nor svcfoo. It also matches every origin whose part before a slash is svc, such as svc/HealthCheck, because matching considers only that part.

A producer therefore SHOULD choose an origin that names it stably and distinguishably, and SHOULD use dots for hierarchy, because an administrator writing an access rule has nothing else to write it against.

An origin MUST match:

origin    := component | component "/" producer
producer  := component | component "[" [0-9]+ "]"
component := [A-Za-z0-9_][A-Za-z0-9_.-]*

A collector MUST discard a record whose origin does not.

A single component is the identifier grammar of §3.19, widened only to admit a leading digit, and names a program. The optional second component names a producer within that program — a hook, a reload command, a health check, one submitted job — and the bracketed integer distinguishes several of the same kind. A forwarding service manager uses it for exactly that: jellyfin/ExecStartPre[0] is a pre-start hook's output, jellyfin/HealthCheck a health check's, jobs/<guid> a submitted job's. There is at most one slash, and the index may appear only after one.

Everything the grammar still excludes, it excludes on purpose. An origin is not merely a label: it is matched against patterns in which * is the wildcard, so an origin containing * could not be selected exactly and could match a rule its producer was never meant to satisfy; and it is the name an access rule is stored under, so an origin carrying a backslash, a quoting character or whitespace could land somewhere other than where the administrator who wrote the rule believes it is. Constraining the producer is the only point at which either can be prevented.

The slash is safe there only because it is never written into a stored rule. A collector MUST resolve the access rule for an origin from the part before the slash, so that a service's hooks, reloads, health checks and jobs answer to the rule written against the service — and a producer cannot reach a rule of its own by inventing a second component.

A collector MUST count discarded origins and MUST make that count observable to an operator. An unrecognised origin means a producer's vocabulary and a collector's have drifted apart, and every line that producer sends is being dropped; a silent discard makes the most complete kind of log loss the least visible one.

Quoted forms remain valid syntax everywhere an origin may be written (§3.24). A single-component origin beginning with a letter or _ never needs them; every other conforming origin does, because the query language's identifiers admit neither a slash, a bracket nor a leading digit (§3.19). A pattern may need them too, and a collector holding origins stored before this rule applied must still be able to return and select them.

3.7.3 is_error #

is_error is a boolean and deliberately not a severity level.

A forwarding producer can distinguish standard output from standard error and nothing more; inventing five levels out of two file descriptors would be a guess presented as data. A producer with real severity levels either writes them into the message text, where they are text and are searched as text, or emits events, which have types.

A system service SHOULD write informational messages to standard output and warnings and errors to standard error. This keeps informational startup and status messages out of is_error == true queries. Warnings stay in them: they are diagnostics on standard error, and the collector does not infer severity from text. Peios's own system daemons follow this rule. A daemon's best-effort kernel-log mirror, where it has one, is independent of the stream a line is written to.

3.7.4 timestamp #

A producer SHOULD supply the timestamp it captured when the line was produced, not when it submitted it. A producer that batches (§3.8) and omits the field attributes every line in the batch to the moment the collector happened to read it, which discards the timing information the batch was accumulated over.

3.7.5 job_id #

job_id correlates a line to a single execution rather than to a program. A forwarding producer sets it so that the output of one run of a service can be separated from the run before and the run after; a producer with no such notion omits it.

A collector MUST treat it as an opaque 16-byte value. Nothing in this chapter interprets it, and a producer MAY use it for any correlation of its own, provided the value is a GUID.

3.8 Validating a Log Record

Peios / Peios technical reference / PSPU / Observability Interfaces

Every failure on this channel is silent (§3.4). What differs between failures is how much is lost: the whole datagram, one record, or only one field.

3.8.1 The three scopes of failure #

The datagram is discarded when it cannot be resolved into records at all:

  • it is not valid MessagePack
  • it decodes to something that is neither a map nor an array of maps
  • the kernel reported it truncated (§3.6)

One record is discarded, and the others in the same datagram are still processed, when the record itself is unusable:

  • a required field is absent
  • a required field has the wrong type — origin an integer, say
  • origin is the empty string
  • the map contains a duplicate top-level key

One field is ignored, and the record is still stored, when an optional field is unusable:

  • timestamp is not an integer, is negative, or is outside the timestamp domain (§3.5)
  • job_id is not binary, or is binary of a length other than 16

A collector MUST implement all three scopes as stated. In particular it MUST NOT discard a record because an optional field was malformed: a producer with a broken clock or a mangled correlation key still has a log line worth keeping, and the field it got wrong is the field of least value in the record.

3.8.2 Duplicate keys #

A record map carrying the same top-level key twice MUST be discarded.

A collector MUST NOT resolve the duplicate by taking the first or the last. MessagePack decoders differ on which they keep, and the fields here are entirely producer-controlled, so a rule that depended on decoder behaviour would let a producer choose which of two origin values a given collector saw. Discarding is the only answer that is the same everywhere.

3.8.3 Batches #

A batch is validated per record. A malformed record in a batch MUST NOT cost the valid records beside it.

A producer SHOULD batch under sustained load. Batching amortises the syscall over many records, and the ceiling (§3.6) is per datagram, so a batch is also the only way to use the channel's capacity efficiently.

The encoded datagram, batched or not, MUST NOT exceed the ceiling. A producer that batches without bounding the encoded size will eventually build a datagram that is discarded whole — which is the one case where batching loses more than sending singly would have.

3.8.4 What a collector adds #

A collector supplies the boot ID (§3.2) and, when the record omitted timestamp, its own clock reading at receipt. It MUST NOT alter any other field, and MUST store message byte-for-byte as given.

A collector MUST NOT parse message. If the text happens to be JSON, or logfmt, or anything else structured, that is the producer's business: this interface carries lines, and a producer with structured data to record emits an event instead (§3.4).

3.9 The Metric Channel

Peios / Peios technical reference / PSPU / Observability Interfaces

A collector MUST expose an AF_UNIX SOCK_DGRAM socket for metric ingestion, protected by a Security Descriptor as §3.3 requires, separate from the log socket.

Before its first send, a producer MUST enable KACS_SO_PASS_TOKEN on its persistent sending socket. KACS then attaches the producer's effective token to every datagram as KACS_SCM_TOKEN. A collector MUST receive with ancillary space for that token and MUST discard the whole datagram when the token is absent or the data or control message was truncated.

For every valid record, the collector resolves the metric name's publication policy and runs KACS AccessCheck for EVENTD_PUBLISH against the conveyed token. A denied record is discarded silently. Records with other authorized names in the same datagram remain valid. An implementation MAY cache a verdict by stable token identity, token modification identity, metric name and policy generation.

The channel works exactly as the log channel does, for the reasons given there: the 262144-byte portable datagram ceiling, truncated datagrams discarded whole, a receive queue of at most four times the collector's declared ceiling, no backpressure and no notification (§3.6). A collector MUST accept at least the portable ceiling and a portable producer MUST bound its encoded datagrams to it. A producer that may send a datagram longer than its socket's default send capacity SHOULD first raise SO_SNDBUF, as on the log channel (§3.6).

Note

§3.A gives the mainline value and upward-adjustable range for this bound and every other in this chapter.

3.9.1 A sink, not a collector of its own #

The collector is pushed to. It MUST NOT scrape an endpoint, read a kernel interface, or poll anything to obtain metrics; every sample it holds arrived on this socket because a producer sent it.

What gathers the measurements is a separate concern and a separate program. A collection agent that reads system counters and submits them is an ordinary producer here, with no privileged position and no interface of its own.

Note

The push direction follows from the loss model rather than from taste. A pulling collector must reach every producer on a schedule, which makes it responsible for their availability and makes a slow producer the collector's problem. Pushing keeps each producer responsible for its own submissions and keeps a slow or dead producer invisible to everything except the gap it leaves.

3.9.2 Batching #

Batching matters more here than it does for logs. It amortizes both the datagram and conveyed-token costs. A collection sweep produces many samples at once — every CPU core, every disk, every interface — and they share a moment, so a producer SHOULD submit a sweep as one batched datagram rather than as one datagram per sample.

The rules are the log rules: one map or an array of maps, per-record validation, and the encoded datagram bounded by the ceiling (§3.8, §3.12).

3.10 The Metric Data Model

Peios / Peios technical reference / PSPU / Observability Interfaces

A metric is a quantity that varies and is worth watching over time: a utilisation, a queue depth, a running total, a distribution of latencies. Metrics are dense where events and logs are sparse — many measurements of the same thing rather than a record of a thing that happened — and the model reflects that.

A sample is one measurement, of one time series, at one moment. It carries:

  • a name, identifying what is measured
  • a label set, identifying which instance of it
  • a type, fixing how the value is to be read
  • a timestamp
  • a value, whose shape depends on the type

Name and labels together identify the series (§3.13). The type is a property of the series, not of the sample.

3.10.1 Names #

A name MUST match the identifier grammar of §3.19:

[A-Za-z_][A-Za-z0-9_.-]*

A collector MUST discard a record whose name does not, for the same two reasons an origin is constrained (§3.7): names are matched against patterns in which * is the wildcard, and names are what read access is granted on.

Beyond that, naming is convention and a collector MUST NOT enforce any. The conventions in use are a dot-separated hierarchy from general to specific, the unit as the last component, and a cumulative name for a cumulative quantity:

system.cpu.usage
disk.read.bytes
request.duration.seconds
http.requests.total

3.10.2 Labels #

Labels are the dimensions of a measurement: which core, which device, which method. cpu.usage with core="0" and cpu.usage with core="1" are two series, not two samples of one.

Label keys and values MUST be non-empty UTF-8 strings. A key MUST match the identifier grammar above; a value MUST NOT contain = (0x3D) or , (0x2C), which are reserved as delimiters in the collector's canonical representation of a label set (§3.13). A key MUST NOT be repeated within one sample, and MUST NOT be any of the six fixed field names a metric result carries — timestamp, boot_id, name, type, value, overflow — because labels and fixed fields share one flat namespace in a result record (§3.22) and a collision would make the record ambiguous.

A record violating any of these is discarded (§3.12).

Note

Label keys are constrained to the identifier grammar for the reason that matters most in practice: a key that cannot be written in a query cannot be filtered on, grouped by, or granted access to. A label you cannot address is storage you cannot reach.

Label cardinality is the producer's responsibility. Each distinct combination of label values is a distinct series, so labels whose values are unbounded — request identifiers, user-supplied strings, timestamps — produce series without limit, and a collector is required neither to cap them nor to degrade gracefully when they arrive. A producer SHOULD use labels whose value sets are small and known. A dimension that is not bounded belongs in an event payload, where it costs one field, not in a label, where it costs a series.

A collector may bound its total metric storage through retention without limiting series creation. Peios eventd does this by default: it continues accepting new series but removes the oldest samples when the metric store's logical live size exceeds its configured ceiling. That protects the volume; it does not make high-cardinality labels safe, because an active set larger than the series cache can still thrash ingestion.

3.10.3 Types #

The type is fixed when the series is first seen and is immutable. A sample that resolves to an existing series but declares a different type is discarded (§3.12), permanently and without a response to the producer. A producer that changes the type of a metric it already emits has stopped emitting it. The mainline collector counts these conflicts, retains the latest metric name and expected and received types in memory, and may report them through its bounded operator diagnostics (§3.12).

3.10.3.1 Counter #

A value that only increases, and resets to zero when the producer restarts. Used for cumulative quantities: requests served, bytes transmitted, errors encountered.

A counter value MUST be a finite, non-negative binary64 value.

The raw value is rarely what a reader wants; the rate of change is (§3.25). A decrease is read as a restart rather than as a negative change, which is why the type must be declared: the same number sequence means something different for a gauge.

3.10.3.2 Gauge #

A value that may move in either direction. Used for current state: a utilisation, an amount in use, a depth, a temperature.

A gauge value MUST be a finite binary64 value, and MAY be negative.

3.10.3.3 Histogram #

A distribution of observations across buckets the producer chose. Used where the shape matters more than the mean — latencies above all.

A histogram value carries:

  • boundaries: a non-empty array of bucket upper bounds, strictly increasing in the order given
  • counts: one cumulative count per boundary, each being the number of observations less than or equal to that boundary; non-decreasing, and each no greater than the total
  • total_count: the number of observations
  • sum: the finite sum of the observations

Boundaries are part of the series identity (§3.13). A collector MUST NOT sort or reinterpret them; a producer that changes them has started a new series, and SHOULD therefore keep them fixed for the life of a metric.

The final count MAY be less than the total: observations above the highest boundary are the difference between them, and are not otherwise represented. A total of zero is a valid empty sample, in which case every count and the sum MUST be zero.

Note

Observations above the highest boundary are counted but not located. A reader asking for a high percentile of a distribution whose tail overflows gets an explicit overflow result rather than a wrong numeric answer (§3.25), so a producer SHOULD choose a highest boundary above the values it expects to see.

3.10.4 Values are floating point #

Numeric input MAY be a MessagePack integer or a MessagePack float; both are converted to binary64 with round-to-nearest, ties-to-even. Every value a collector stores and every numeric value a query returns is a finite binary64. The null value in an explicit percentile-overflow result is not a numeric metric value (§3.25).

Non-finite values are refused rather than stored: a record whose value converts to NaN or to either infinity is discarded (§3.12). There is no representation for a missing measurement — a producer with nothing to report sends nothing, and the gap is the answer (§3.13).

3.11 Metric Records

Peios / Peios technical reference / PSPU / Observability Interfaces

A metric datagram carries one record, encoded as a MessagePack map, or several, encoded as an array of maps. A collector MUST accept both (§3.9). Each map is one sample of one series.

3.11.1 Fields #

FieldTypeRequiredMeaning
namestringyesThe metric name (§3.10).
labelsmapnoKey-value string pairs. Absent means the series has no labels, which is not the same as a series whose labels are empty — it is the same series.
typestringyesExactly "counter", "gauge" or "histogram", lowercase.
timestampintegernoWhen the measurement was taken, in the timestamp domain (§3.5). Absent means the collector uses its own clock at receipt.
valuevariesyesThe measurement. A number for counter and gauge; a map for histogram.

A collector MUST ignore fields it does not recognise (§3.29).

3.11.2 The histogram value #

For a histogram, value is a map:

FieldTypeMeaning
boundariesarray of numberNon-empty, finite bucket upper bounds, strictly increasing after conversion to binary64, in the order given.
countsarray of integerCumulative count per boundary. Same length as boundaries. Non-decreasing, each no greater than total_count.
total_countintegerNumber of observations.
sumnumberFinite sum of the observations.

Counts and total_count MUST be MessagePack unsigned integers, or non-negative signed integers. boundaries and sum MAY be integers or floats and are converted as §3.10 requires.

3.11.3 type is per record, not per series #

Every record declares its type, including the second and every subsequent sample of a series that already exists.

This is redundant on the wire and deliberately so. A producer holds no state about what a collector already knows, has no way to ask, and MUST NOT be required to establish a series before sampling it: the first sample of a series and the millionth are the same message. The redundancy is what makes a producer stateless, and the cost is one short string per sample.

The collector uses the declaration only on first sight. Afterwards it is a consistency check, and a record that fails it is discarded (§3.10).

3.11.4 Timestamps need not increase #

A collector MUST store a valid sample whose timestamp is older than samples it already holds for that series.

Producers batch, clocks step, and a collection sweep may be submitted out of order or retried. A collector that refused late samples would turn any of those into silent data loss, so it accepts them and defines every ordering it performs over timestamp rather than over arrival (§3.21, §3.25). Two samples of one series MAY share a timestamp; the collector orders them deterministically and a client MUST NOT depend on which comes first.

3.12 Validating a Metric Record

Peios / Peios technical reference / PSPU / Observability Interfaces

Failures on this channel are silent, exactly as on the log channel (§3.4, §3.8). The scopes are the same, with one difference that matters: a metric record has no ignorable field. Every field a metric record carries participates either in the series identity or in the measurement, so there is nothing whose loss leaves a usable record behind. A malformed timestamp costs the log line nothing and costs the sample everything.

3.12.1 The datagram is discarded #

  • it is not valid MessagePack
  • it decodes to something that is neither a map nor an array of maps
  • no KACS sender token arrived
  • the kernel reported its data or ancillary data truncated (§3.9)
  • the collector cannot query the sender token or resolve publication policy

3.12.2 The record is discarded #

A collector MUST discard a record, leaving the rest of its batch untouched, for any of the following.

Structure

  • a required field is absent, or has the wrong type
  • the map contains a duplicate top-level key
  • type is not exactly "counter", "gauge" or "histogram"

Name and labels (§3.10)

  • name is empty or does not match the identifier grammar
  • a label key or value is not a string, or is empty
  • a label key does not match the identifier grammar
  • a label value contains = or ,
  • a label key is repeated within the record
  • a label key is one of timestamp, boot_id, name, type, value, overflow

Timestamp

  • timestamp is present and is not an integer, is negative, or is outside the timestamp domain (§3.5)

Counter and gauge values

  • the value is not a number
  • it converts to a non-finite binary64
  • it is negative and the type is counter

Histogram values

  • the value is not a map, or its map has a duplicate key
  • a field is absent or has the wrong type
  • boundaries is empty
  • a boundary or sum converts to a non-finite binary64
  • counts and boundaries differ in length
  • the converted boundaries are not strictly increasing
  • a count is negative, the counts are not non-decreasing, or a count exceeds total_count
  • total_count is zero and any count or sum is non-zero

Series consistency

  • the record resolves to an existing series whose type differs (§3.10)

Publication policy

  • the conveyed KACS token is denied EVENTD_PUBLISH for name (§3.9)

3.12.3 Why the timestamp rule differs from logs #

On the log channel a malformed timestamp is ignored and the record kept; here it discards the record.

The asymmetry is not an inconsistency. A log line with the wrong time is still the line, and reading it is still worth doing. A sample is a (time, value) pair and nothing else: attaching the collector's receipt time to a measurement taken at an unknown moment does not recover the sample, it fabricates one, and it fabricates one that will be charted next to real ones. Discarding leaves a gap, which is honest (§3.4).

Absence is different from malformation. A record that simply omits timestamp is asserting "now", and the collector's clock is the right answer to that.

3.12.4 Silence, again #

A collector MUST NOT emit an event, write a durable log, or increment anything a query client can observe in response to any failure in this article — with no exception for the series-consistency failure, which is the one that most looks like it deserves one. Local in-memory diagnostic counters and rate-limited standard-error output are permitted by §3.4.

A producer that changes a metric's type is misconfigured, and the misconfiguration is permanent: every sample is discarded for as long as the series exists, and the producer receives no response. Peios eventd increments an in-memory total, retains the latest conflicting metric name and expected and received types, emits at most one coalesced standard-error warning per minute, and includes the total and latest conflict in its SIGQUIT diagnostic dump. None of those signals is part of the query interface or grows in proportion to rejected input.

3.13 Series Identity

Peios / Peios technical reference / PSPU / Observability Interfaces

Two samples belong to the same time series when they agree on:

  • the name, compared exactly, byte for byte; and
  • the label set, compared as an unordered set of key-value pairs, each compared exactly; and
  • for histograms, the bucket boundaries, compared as an ordered sequence of binary64 values.

Nothing else participates. Type does not: a record whose type disagrees resolves to the series and is then discarded for disagreeing (§3.10). Boot ID does not: a series continues across a reboot, and a client that wants one boot's worth filters for it (§3.25). Time does not.

3.13.1 Order does not distinguish a label set #

{core: "0", host: "a"} and {host: "a", core: "0"} are the same series. A collector MUST compare label sets as sets.

To do so it needs a canonical form, and the form in use is the reason label values may not contain = or , (§3.10): pairs are sorted by key in unsigned UTF-8 byte order, each written key=value, and joined with commas. Because neither delimiter can occur inside a key or a value, no escaping is needed and no two distinct label sets can produce the same string.

The byte form itself is the collector's business and this chapter does not require it. What it requires is the property: a label set has exactly one identity, independent of the order the producer wrote it in. The delimiter reservation is stated normatively because it binds the producer, and a producer cannot see the encoding that motivates it.

3.13.2 Absent labels and empty labels #

A record with no labels field, a record with an empty labels map, and a record whose labels were all discarded are the same series: the one with no labels. There is no distinction between "unlabelled" and "labelled with nothing".

3.13.3 Boundaries are identity, not metadata #

Two histogram samples with different boundaries are different series even when name and labels agree.

This is the consequence that surprises producers, and it is unavoidable: cumulative counts against one set of bucket edges cannot be compared with counts against another, so calling them one series would mean computing percentiles across incommensurable distributions. A producer that re-tunes its buckets each collection cycle creates a series each cycle, each holding a single sample and each surviving until retention removes it.

A collector MUST NOT defend against this. It is a producer defect, it is indistinguishable at the interface from legitimately introducing a new metric, and every defence available — capping series, merging near-equal boundary sets, rejecting a second boundary set for a name — would break a correct producer to inconvenience an incorrect one.

3.13.4 Series are created, never announced #

A series comes into existence when its first sample arrives. There is no registration message, no schema, and no way to declare a series in advance or to retire one.

A collector MUST NOT require a series to be known before a sample of it is accepted, and MUST NOT limit how many series exist. A series with no remaining samples ceases to exist when retention removes the last of them; nothing else removes one.

Note

The absence of a cap is a deliberate and load-bearing decision, and it is worth being clear about what it costs. Labels are producer-supplied and are not part of publication authorization (§3.9), so a producer authorized for one metric name can create series beneath that name without limit. Name authorization bounds the namespace, not its label cardinality. Without a byte ceiling, each series persists until the retention window elapses. A collector-level ceiling may remove the oldest samples, and then their orphaned series, earlier; it bounds storage rather than admission and does not change this rule.

3.13.5 Gaps are preserved #

A collector MUST NOT interpolate, backfill, or synthesise a sample that a producer did not send.

A missing sample is a real fact about the system — the producer was down, the datagram was dropped, the sweep was late — and it is a fact the metric is often being watched for. A series with a hole in it is returned with a hole in it, and a client that wants a value across the hole computes one itself.

3.14 The Query Channel

Peios / Peios technical reference / PSPU / Observability Interfaces

A collector MUST expose an AF_UNIX SOCK_STREAM socket for queries, protected by a Security Descriptor as §3.3 requires.

One socket serves all three data types. The mode a query runs in — events, logs or metrics — is determined by parsing the query string (§3.18), never by the transport, so a client needs no connection setup, no mode selection, and no separate endpoint per data type.

3.14.1 One query per connection #

A connection carries exactly one query. A client that wants two concurrent queries opens two connections.

A collector MUST close the connection after the terminal message of a non-streaming query (§3.16), and MUST treat a client disconnect as cancellation of a streaming one.

Note

This is the opposite of the identity-lookup channel in PGSS §2.14, which multiplexes tagged requests over one connection. The reason is the shape of the work rather than a difference in taste: a lookup is small, uniform and answered in one message, so correlating many on one connection saves real cost. A query may run for thirty seconds, may stream indefinitely, and may be cancelled — and every one of those is expressed by the connection itself, with no correlation identifier and no cancellation message to specify.

3.14.2 Identity #

A collector MUST establish the client's identity from the connected socket, before executing anything, by obtaining the peer's token. This is possible here and not on the ingestion channels, and it is the whole reason the query channel is a stream (§3.3).

The token is captured once, at connection time, and is a snapshot. A client whose privileges change while a query runs — and in particular while a streaming query runs, which may be indefinitely — is evaluated throughout against the token it connected with.

If a collector cannot obtain the peer token, it MUST refuse the query. It MUST NOT execute a query for an unidentified caller, and MUST NOT fall back to any other means of identifying one.

3.14.3 No credentials cross this channel #

There is no message with which a client offers a credential and none with which a collector asks for one. Identity is established from the connection and from nothing else.

3.14.4 Concurrency #

A collector MUST bound the number of queries it will execute at once, and MUST reject a query beyond the bound with an error rather than queueing it behind the others.

The streaming bound is the lower of the two and is enforced separately, because a streaming query holds its resources for as long as its client stays connected while an ordinary one holds them for at most a timeout (§3.16).

Note

§3.A gives the mainline value and adjustable range for this bound and every other in this chapter.

Both bounds are global. Because every authenticated caller may connect (§3.3), a collector MUST also bound the queries one caller may have running at once, so that one caller cannot occupy every slot. The caller is the user SID of the token captured at connect. A collector MAY leave SYSTEM's queries uncounted. A query beyond the caller's bound is rejected with an error, like one beyond the global bounds.

Neither bound may affect ingestion: queries and ingestion are separate channels precisely so that exhausting one cannot exhaust the other (§3.3).

3.15 Query Framing

Peios / Peios technical reference / PSPU / Observability Interfaces

Every message in either direction is a length-prefixed MessagePack value:

OffsetSizeFieldValue
04lengthLength of the payload in bytes
4lengthpayloadThe request or response body

length is little-endian, as PSPU §1.2 requires of every integer field in this document. It counts the payload only; the four bytes of the prefix are not included.

There is no magic value and no version field. The channel is a Unix socket at a configured path, so there is no possibility of reaching the wrong service by accident in the way a shared header guards against (PSPU §2.7), and versioning is handled as §3.29 describes.

3.15.1 The two size controls #

A collector declares two independent sizes. The query request ceiling is a hard inbound limit. The query response target is a soft outbound batching target. They are separate because an untrusted client must not be able to force an arbitrarily large allocation merely by declaring a request length, while a record the collector has already accepted must remain possible to return.

Note

§3.A gives the mainline value and adjustable range for both sizes and every other bound in this chapter.

Inbound. A collector MUST refuse a request whose length exceeds the request ceiling. It MUST do so without reading the payload — the point of checking the prefix is to avoid allocating for a request that a malicious or broken client has declared too large — and it MUST send an error response (§3.16) before closing the connection. A collector MUST NOT close on an oversized request silently: a bare close is indistinguishable from a crash, and leaves a client unable to tell that shortening its query is the remedy.

Outbound. A collector SHOULD chunk result records so an ordinary response does not exceed the response target (§3.16). The target is not a ceiling: when one complete record would exceed it, the collector MUST send that record alone in one response rather than split, truncate, skip, or reject it.

3.15.2 Every stored record must remain returnable #

A collector MUST NOT store a record whose encoded result representation cannot fit in the protocol's u32 payload length. This structural framing bound is the only outbound ceiling.

A single result record is never split across messages (§3.16). A collector MUST therefore ensure that its accepted record encodings have a bounded result expansion that fits one frame. It MAY establish that statically from the protocol's record limits; it need not flatten or encode each record on the ingestion path merely to check response size. An implementation MAY encode a large response incrementally after writing its known length; it need not allocate another buffer as large as the record.

The ingestion ceilings (§3.6, §3.9) remain independent configuration. Raising one does not require raising the response target: it merely makes a single-record response above that target more likely. The structural u32 requirement is a property of the wire encodings, not a configurable coupling between those values.

3.15.3 Requests #

A request is a MessagePack map:

FieldTypeRequiredMeaning
querystringyesThe query string (§3.18).

A collector MUST send an error response and close the connection if the payload is not valid MessagePack, is not a map, omits query, gives query a non-string value, or contains a duplicate top-level key. It MUST ignore unrecognised fields that are not duplicates (§3.29).

Unlike the ingestion channels, nothing here is silent. A query client is identified (§3.14), is one of a bounded number, and is asking a question, so telling it what went wrong is neither an amplification vector nor an information leak — with the one exception §3.28 sets out.

3.16 Responses

Peios / Peios technical reference / PSPU / Observability Interfaces

A response is a MessagePack map whose status field names its kind. There are four.

statusCarriesMeaning
"ok"recordsA chunk of result records.
"end"—The non-streaming query is complete.
"watch"—The streaming query's initial result set is complete.
"error"errorThe query failed.

3.16.1 Result messages #

An "ok" message carries records, an array of flat maps (§3.22).

Records are chunked at record boundaries: a successful query sends one or more "ok" messages, ordinarily at or below the response target (§3.15). A collector MUST NOT split one record across two messages. If one complete record exceeds the target, that record occupies an otherwise empty "ok" message by itself. It MUST NOT be truncated, partially sent, skipped, or turned into a query error merely because it exceeds the target.

Each record is self-describing and records in one response MAY carry different sets of keys — event payload fields vary by event type, metric labels vary by series. A client MUST NOT assume a uniform schema across a result set, and MUST NOT infer that a key absent from one record is absent from the data.

A successful query with no matching records sends exactly one "ok" message with an empty records array, then its terminal message. A collector MUST NOT omit it: "no records" and "the query has not yet produced records" are different states and a client must be able to tell them apart.

3.16.2 The two terminal messages #

"end" terminates a non-streaming query. "watch" marks the point in a streaming query where the stored result set ends and live delivery begins (§3.27). A query sends exactly one of them, never both.

Until one has arrived, the query has not succeeded. A collector that fails partway through MUST send "error", and a client that receives "error" before either terminal message MUST discard every "ok" message it received for that query. Partial results are not results: they are an arbitrary prefix of an ordering that was never completed, and a client that kept them would silently under-report.

An "error" after "watch" is different. It terminates the stream, and the records already delivered remain valid — they were complete when they were sent, and the ordering they belonged to had already closed.

3.16.3 Errors #

An "error" message carries error, a human-readable string.

There is no error code and no machine-readable classification. This is a deliberate limit on the interface: an error here is a parse failure, a type mismatch, a timeout, a limit, or a refusal, and a client's response to all of them is the same — show it to whoever wrote the query. A client MUST NOT parse the string, and a collector MAY change the wording of any error at any time.

A collector MUST NOT include in an error message any value the client was not authorized to read (§3.28).

3.16.4 Timeouts #

A collector MUST bound the time a query may take to reach its terminal message.

Note

§3.A gives the mainline value and adjustable range for this bound and every other in this chapter.

The clock starts once the request has been decoded and the caller's token obtained, and it covers everything that follows: parsing, access checks, cross-type pre-computation, execution, merging, aggregation, pagination, projection and transmission. A collector MUST send "end", or for a streaming query "watch", before it expires.

The timeout bounds the initial result set only. Once a streaming query has sent "watch" its watch phase is not time-limited; what bounds it instead is the streaming concurrency limit (§3.14), the distinct-value limit (§3.27), and the client's own ability to keep up (§3.27).

On expiry a collector MUST cancel the query and send "error". Any "ok" messages already sent are discarded by the client under the rule above.

3.16.5 Memory #

A non-aggregating query in the default order (§3.21) needs to hold nothing while it runs: its records can be read in that order and sent as they are read. A collector SHOULD answer it so, and MUST NOT refuse it for the size of its result, however large. A client that wants every record of a large store asks for exactly this.

Other queries must hold something to answer: a sort on another field holds the records it orders, or with TAKE only the best SKIP + TAKE of them, and an aggregation holds its groups. A collector MAY bound the memory queries hold. When a query would exceed the bound, the collector MUST fail it with "error". It MUST NOT truncate the result, drop groups, or answer from part of the data instead.

Note

§3.A gives the mainline bound. The mainline collector applies one bound to every running query together, as it does for concurrency (§3.14), so a query can fail because others hold memory. Retrying it later may succeed.

3.17 Value Encoding

Peios / Peios technical reference / PSPU / Observability Interfaces

Every value in a result record is encoded as follows.

ValueEncoding
IntegerMessagePack integer
FloatMessagePack float64
TimestampMessagePack integer, nanoseconds since the Unix epoch (§3.5)
StringMessagePack string
GUIDMessagePack string, PCDS canonical form
BinaryMessagePack bin
BooleanMessagePack boolean
ArrayMessagePack array
Absent or nullMessagePack nil

A GUID is rendered as a string rather than as sixteen bytes because a result record is read by people as often as by programs, and a raw GUID in a terminal is unreadable. The canonical form is lowercase 8-4-4-4-12 hexadecimal within braces, as PCDS defines it. A query comparing against a GUID accepts either braced or unbraced input, and compares case-insensitively (§3.19); a result always uses the canonical form.

3.17.1 Maps do not appear as values #

An event payload is a MessagePack map, and result records are flat (§3.22). A map in a stored payload is therefore a container to be flattened, not a value to be emitted: its entries become top-level keys of the record, joined by dots, and the map itself never appears.

Arrays are different. An array is emitted as an array value at its flattened path, and a collector MUST NOT traverse into it. Maps nested inside an array are preserved as that array's contents, unflattened and unqueryable.

The asymmetry is deliberate. A map has keys, so its entries have names that can be addressed, granted access to and indexed. An array has positions, and a path like hops.3.address would mean something different in every record — so an array is carried across whole and treated as one value.

Being one value is also why == against an array field compares the whole array. The question usually being asked of one — does it hold this element — is HAS (§3.20).

Binary values in a payload stay binary. A collector MUST NOT render bin as a string, in either direction.

3.17.2 Missing and null are the same value #

A field absent from an event payload or from a metric's label set encodes as nil, exactly as an explicitly null one does, and the two are indistinguishable in a result record.

This is consistent throughout: they compare equal, they sort together, and they group together (§3.20, §3.21). A collector MUST NOT distinguish them anywhere in the query surface, and a client MUST NOT attempt to.

3.18 The Query Language

Peios / Peios technical reference / PSPU / Observability Interfaces

A query is one string. It names a mode, narrows to some data, and says what to do with it.

EVENTS kacs.* SINCE 1h ago WHERE emitter.process.guid == "550e8400-e29b-41d4-a716-446655440000" TAKE 100
LOGS FROM loregd ERROR ONLY CONTAINING "connection refused" SINCE 1d ago
METRIC cpu.usage[core="0"] SINCE 1h ago AVG_OVER 5m

3.18.1 Three modes #

The first token selects the mode, and a collector MUST reject a query whose first token is not one of them.

  • EVENTS searches structured event records, primarily by event type (§3.23).
  • LOGS searches log output, primarily by origin (§3.24).
  • METRIC evaluates measurements, primarily by name and labels (§3.25).

Events and logs are record-oriented: collections you search, returning the records that matched. Metrics are value-oriented: measurements you evaluate, returning numbers computed from samples. The modes differ because the data differs, and forcing all three through one shape would serve none of them.

3.18.2 The primary selector #

Immediately after the mode comes an optional primary selector, specific to the mode: an event type pattern, FROM with one or more log origins, or a metric name with an optional label selector. It narrows the data before anything else runs.

A primary selector MUST NOT be repeated unless its mode defines a list form — LOGS FROM a, b is one selector naming two origins, not two selectors.

3.18.3 Clauses #

Everything after the primary selector is a clause, and clauses MAY appear in any order. EVENTS SINCE 1h ago TAKE 10 and EVENTS TAKE 10 SINCE 1h ago are the same query.

Order of appearance never affects meaning. Execution follows the fixed sequence below regardless of how the string was written, so a collector MUST NOT derive semantics from clause position.

These clauses work identically in all three modes:

ClauseMeaning
SINCE tLower time bound, inclusive.
UNTIL tUpper time bound, exclusive. Defaults to the evaluation time.
WHERE pFilter by a predicate (§3.20).
WHERE METRIC … / WHERE EVENT … / WHERE LOG …Filter by a condition on another data type (§3.26).
SORT f [ASC|DESC], …Order the results (§3.21).
TAKE nReturn at most n.
SKIP nDiscard the first n after ordering.
STREAMDeliver matching records as they arrive (§3.27).

3.18.4 Execution order #

Whatever order the clauses were written in, a collector MUST evaluate them in this sequence:

Phase
1Cross-type conditions, producing time ranges (§3.26)
2The primary selector
3Access control on the primary and cross-type sources (§3.28)
4SINCE and UNTIL
5WHERE, including the ranges from phase 1
6ERROR ONLY and CONTAINING, as WHERE predicates (§3.24)
7Metric transforms (§3.25)
8GROUP
9COUNT BY, TOP N BY, DISTINCT, and the aggregation functions
10Metric window aggregations (§3.25)
11SORT (§3.21)
12SKIP and TAKE
13SELECT (§3.22)

Two positions in that list are load-bearing.

Access control is third, before every filter, aggregate, sort and limit. It is part of the query's logical execution and not a filter applied to the output (§3.28).

SELECT is last. It shapes the output and nothing else; a field it omits is still available to every earlier phase (§3.22).

3.18.5 Repetition #

A clause MUST appear at most once, and a collector MUST reject a repeat as a parse error, with two exceptions:

  • WHERE is repeatable. Multiple WHERE clauses are combined with AND, each treated as a parenthesised group: WHERE a == 1 OR b == 2 followed by WHERE c == 3 means (a == 1 OR b == 2) AND c == 3.
  • SELECT is repeatable where it is valid at all, and is additive: SELECT event.time SELECT event.type names both fields.

Both exist so that a query can be built up in pieces — by a tool appending a filter, or by a person adding one to a query they already have — without rewriting what is already there.

3.18.6 Counts #

TAKE, SKIP and the N of TOP N BY are unsigned decimal integers that MUST fit in 64 bits. A negative, hexadecimal, floating-point or missing count is a parse error.

SKIP defaults to 0. TAKE omitted means no limit. TAKE 0 and TOP 0 BY are valid, and return no records after every earlier phase has run — which is not the same as not running the query, because a TOP 0 BY still counts and a TAKE 0 still enforces access control.

Note

A non-aggregating query without TAKE has no implicit limit, and a broad one over a long range may match millions of records. The timeout (§3.16) is the only backstop.

3.18.7 Case #

Keywords are matched case-insensitively, using ASCII case folding, in grammar positions where a keyword is expected. This document writes them in uppercase by convention only.

Identifiers are case-sensitive, except where the language defines a named alias for a value (§3.23).

A word spelled like a keyword MAY be used where the grammar expects an identifier or a value: LOGS FROM stream selects the origin stream, while LOGS STREAM enables streaming. A collector MUST resolve the ambiguity by grammar position and MUST NOT reserve keywords globally.

3.19 Lexical Rules and Literals

Peios / Peios technical reference / PSPU / Observability Interfaces

A query string is UTF-8. Whitespace separates tokens outside quoted strings and is otherwise insignificant.

3.19.1 Identifiers #

An unquoted identifier is ASCII and matches:

[A-Za-z_][A-Za-z0-9_.-]*

Identifiers name fields, payload paths, metric names, label keys, event type patterns, log origins and value aliases. ., _ and - are permitted inside one; /, :, whitespace, quotes, brackets, parentheses, commas and the comparison operators are not.

This grammar is the same one that constrains a metric name and a metric label key at ingestion (§3.10), which is what makes every stored metric identifier writable here without quoting. A log origin is the one exception: it is constrained to a near relative of this grammar (§3.7) that also admits a leading digit and a producer component such as /ExecStartPre[0], and an origin using either is written as a quoted string.

A value that cannot be written as an identifier MUST be written as a quoted string. Quoted forms are accepted anywhere an identifier is — they are never required for a conforming identifier, but a pattern may need one, and a collector holding identifiers stored under an earlier revision must still be able to select them.

3.19.2 Strings #

A string literal is double-quoted UTF-8. The escapes are \", \\, \n, \r, \t, and \uXXXX for a scalar value in U+0000 to U+FFFF written as four hexadecimal digits.

A collector MUST reject any other backslash escape as a parse error, and MUST reject \uXXXX naming a surrogate code point in U+D800 to U+DFFF. Surrogate pairs are not decoded: a character outside the basic multilingual plane is written directly as UTF-8, not as two escapes.

Note

Refusing surrogates rather than pairing them means there is exactly one way to write every character, and no way to write a sequence that is not valid UTF-8. A language that accepts lone surrogates has to decide what they mean when compared against stored text that cannot contain them.

3.19.3 Binary #

A binary literal is a lowercase x, a double quote, an even number of hexadecimal digits, and a closing quote:

WHERE target_sid == x"010500000000000515000000"

Hexadecimal digits inside are case-insensitive. x"" is valid and is the empty byte string. Whitespace inside the payload, an odd digit count, and any non-hexadecimal character are parse errors.

A binary literal compares only against MessagePack bin values. A collector MUST NOT coerce one to a string or a string to one: x"6162" and "ab" are different values and never compare equal (§3.20).

3.19.4 Integers #

An integer literal is decimal or hexadecimal.

WHERE emitter.class == 2
WHERE access.granted == 0x1F01FF

A decimal integer MAY carry a leading -, in which case it MUST fit in signed 64 bits; without one it MUST fit in unsigned 64 bits. A hexadecimal integer is 0x followed by one or more digits, is always non-negative, and MUST fit in unsigned 64 bits. A leading + is not valid. An out-of-range literal is a parse error.

3.19.5 Floats #

A float literal is a finite decimal number with an optional leading - and either a fractional part or an exponent: 42.0, 0.001, 1e6, -1.25e-3. A token that looks like an integer, such as 42, is an integer literal and not a float.

Float literals are binary64 and MUST be finite. NaN, Infinity, -Infinity and any literal that overflows to infinity are parse errors. A leading + is not valid.

3.19.6 Booleans and null #

true and false are matched case-insensitively with ASCII folding.

NULL, likewise folded, is valid only in IS NULL and IS NOT NULL. A collector MUST reject field == NULL and field != NULL as parse errors rather than evaluating them.

Note

Equality against null is refused rather than defined because every definition of it is a trap. Under three-valued logic it is neither true nor false, which no other operator here does; under two-valued logic it silently disagrees with SQL. IS NULL says what was meant and cannot be misread.

3.19.7 Durations #

A duration is an unsigned decimal integer followed immediately by s, m, h or d — seconds, minutes, hours or days. A zero duration is a parse error.

3.19.8 Times #

LiteralMeaning
<duration> agoThat duration before the evaluation time.
<duration> henceThat duration after it.
todayMidnight of the current day, UTC.
yesterdayMidnight of the previous day, UTC.
YYYY-MM-DDMidnight of that date, UTC.
YYYY-MM-DDTHH:MM:SSThat instant, UTC.

Absolute literals are a fixed UTC subset. Components MUST be zero-padded exactly as shown, the date MUST be a valid Gregorian date, hours are 00–23, minutes and seconds 00–59. Leap seconds are not accepted. Timezone suffixes and fractional seconds are not part of this revision and MUST produce a parse error.

3.19.9 The evaluation time #

A collector MUST capture the evaluation time once, before execution begins, and MUST use that one reading for every ago, every hence, and for an omitted UNTIL, throughout the query — including throughout the watch phase of a streaming query.

A query that read the clock more than once could produce a range whose end preceded its start, or a window that grew while it was being scanned. One reading makes the effective query range a fixed interval for the life of the query.

SINCE is inclusive, UNTIL is exclusive: the effective query range is [SINCE, UNTIL). If SINCE is greater than or equal to UNTIL the query returns no records — which is a successful query with an empty result (§3.16), not an error. A time literal that evaluates outside the timestamp domain MUST produce an error (§3.5).

3.20 Comparison and Logic

Peios / Peios technical reference / PSPU / Observability Interfaces

3.20.1 Operators #

OperatorMeaningOperand types
==Equalany
!=Not equalany
> >= < <=Orderinginteger, float, timestamp
STARTS_WITHPrefixstring
ENDS_WITHSuffixstring
CONTAINSSubstringstring
HASArray contains an elementarray
INMember of a setany
NOT_INNot a memberany
IS NULLAbsent or nullany
IS NOT NULLPresent and not nullany

IN and NOT_IN take a non-empty parenthesised, comma-separated list of literals. An empty list is a parse error.

WHERE origin IN ("loregd", "peinit")
WHERE emitter.class NOT_IN (kacs, lcs)

= is not a comparison operator and MUST produce a parse error, with one exception: inside a metric label selector, where = and == are both equality (§3.25).

3.20.2 HAS asks about an array #

HAS is true when the field holds an array one of whose elements equals the value, compared as == compares it, so a binary literal matches a binary element exactly and a string element folds case.

EVENTS WHERE subject.token.groups HAS x"010200000000000520000000200200"

A payload field MAY hold an array — a token's group SIDs are the canonical example — and == against an array is whole-value equality: an array equals only an array of the same length whose elements are equal in order. That is what an operand of a different type compares against, so == between an array field and a single element is a type mismatch and evaluates false, exactly as §3.20's "types do not coerce" says. HAS is the operator that asks the other question.

HAS applied to a field that is not an array evaluates false for that record, for the same reason an ordering operator applied to a non-numeric value does: a payload field's type varies from record to record. Elements are compared one level deep — an element that is itself an array is compared whole, not searched.

HAS is not a set operator and MUST NOT be confused with IN. IN asks whether a scalar field equals one of several literals; HAS asks whether one array field holds one literal. CONTAINS is substring matching on a string and has nothing to do with either.

A collector MAY evaluate HAS by scanning: array containment need not be indexable, and answering correctly without an index is required where answering quickly is not.

3.20.3 Strings fold case #

Every string comparison — ==, !=, STARTS_WITH, ENDS_WITH, CONTAINS, HAS, IN, NOT_IN — is case-insensitive, using ASCII-only folding: bytes A–Z compare equal to a–z, and every non-ASCII byte compares exactly.

This applies uniformly: to event header fields, to payload fields, to log messages, to metric label values, and to the pattern matching of primary selectors. Integers, floats, GUIDs, timestamps and binary values are unaffected.

Note

Folding is ASCII-only rather than Unicode because full case folding is locale-dependent, version-dependent and expensive, and because the data it would be applied to — identifiers, service names, event types — is ASCII by construction (§3.19). A collector that folded Turkish dotless ı would have to fold it the same way as every other collector, for every Unicode version, forever.

3.20.4 Numbers compare mathematically #

Integers and floats compare by mathematical value, not by casting both to one storage type.

An integer equals a finite float only when the float represents exactly that value. Ordering between an integer and a float MUST be exact, including for integers outside the range binary64 can represent exactly. A collector MUST NOT resolve 9007199254740993 > 9007199254740992.0 by converting the left operand to a float, which would make it false.

3.20.4.1 NaN #

An event payload may carry a NaN (§3.5), though a literal never can (§3.19). Every NaN is one value: it equals every other NaN and nothing else, so x == 5 is false and x != 5 is true for a record whose x is a NaN.

An ordering operator with a NaN on either side evaluates false, as IEEE 754 has it. x < 5 and x >= 5 are both false for that record. Sorting and grouping still need a place for a NaN, and §3.21 gives it one.

3.20.5 Types do not coerce #

Values of different non-numeric types are never equal. The string "1" is not the integer 1, and != between them is true.

An ordering operator applied to a field whose runtime value is non-numeric evaluates false for that record — not an error, because a payload field's type varies from record to record and a query cannot know in advance.

An ordering operator applied to a known fixed field whose declared type cannot be ordered is different: a collector MUST reject the query during parsing or planning rather than executing a predicate that can never match. WHERE message > 5 is a mistake the collector can see, and returning zero records for it would be a wrong answer that looks like a right one.

Binary values compare by exact byte equality under ==, !=, HAS, IN and NOT_IN. Ordering is not defined for binary values, and a predicate applying an ordering operator to a binary literal MUST produce a parse error.

3.20.6 Absent fields #

A field absent from an event payload or from a metric's label set resolves to null (§3.17).

Every comparison against null evaluates false, except IS NULL, which is true, and IS NOT NULL, which is false. In particular WHERE field != "x" does not match records lacking the field: a record with no opinion is not a record with a different opinion.

3.20.7 Combining predicates #

Predicates within one WHERE combine with AND and OR. AND binds tighter than OR. Parentheses override.

Multiple WHERE clauses combine with AND, each parenthesised as a group (§3.18).

There is no NOT. Negation is written with the negative operators — !=, NOT_IN, IS NOT NULL — and a collector MUST reject NOT as a parse error rather than silently treating it as an identifier.

3.21 Ordering, Grouping and Distinct

Peios / Peios technical reference / PSPU / Observability Interfaces

Two rules govern this article, and both exist for the same reason.

Ordering MUST be total and deterministic. For a fixed set of stored records, one query MUST produce one order. Without that, SKIP and TAKE are meaningless: a client paging through results would see records twice and never see others, and would have no way to tell.

Equality here MUST be the query language's, not the storage engine's. A collector that grouped by whatever its database considers equal would group differently depending on how it was built.

3.21.1 SORT #

SORT orders by one or more fields. Each defaults to ascending; ASC may be written, DESC reverses that field.

SORT timestamp DESC
SORT origin ASC, timestamp DESC

If the named fields do not uniquely order two records, a collector MUST append internal tiebreakers until the order is total. The tiebreakers are not query-language fields, are never emitted in a result record, and a client MUST NOT depend on their identity — only on their effect, which is that the order is stable.

When no SORT is present:

  • Events and logs are ordered by timestamp descending, most recent first — the order a person reading a log wants.
  • Metrics are ordered by timestamp ascending, the order a chart wants.

3.21.2 Value ordering #

SORT uses the query language's ordering, not the storage engine's. Missing fields and explicit nulls are equivalent. Ascending order sorts by type first, in this order:

  1. Null
  2. Boolean, false before true
  3. Numeric, integers and floats compared mathematically (§3.20)
  4. String and GUID, ASCII-folded
  5. Binary, unsigned lexicographic
  6. Array

DESC reverses the whole ordering, type order included.

A NaN is numeric and sorts after every other number, the infinities included. Every NaN is one value, so NaNs tie with each other and fall to the tiebreakers. Without a place for it the order would not be total: a NaN is neither less than, greater than nor equal to any number under IEEE 754, and a sort given such a comparison may produce any order at all, or fail.

Strings and GUIDs compare with the same ASCII folding as predicates. Two strings equal under folding are ordered by their original UTF-8 bytes, so that folding never costs totality. Binary values compare as unsigned bytes. Arrays compare by their canonical MessagePack encoding (§3.5).

Maps do not appear as result values (§3.17) and MUST NOT appear as sort keys.

3.21.3 Grouping #

COUNT BY, TOP N BY, GROUP and DISTINCT use query-language equality:

  • missing and null are one group
  • integers and floats that are numerically equal are one group
  • every NaN is one group
  • strings and GUIDs group under ASCII folding
  • binary values group by exact bytes

3.21.4 The canonical representative #

When a group's members are equal under those rules but not byte-identical — "Loregd" and "loregd", or 1 and 1.0 — the value emitted for the group MUST be its canonical representative:

GroupRepresentative
Nullnil
Booleanthe boolean
Numerican integer if every contributing value was an integer; otherwise a float64
String or GUIDthe smallest original UTF-8 byte sequence among the members
Binarythe exact value
Arraythe member with the smallest canonical MessagePack encoding

Choosing the smallest rather than the first makes the representative a property of the set, independent of the order records were read in — which matters because a collector may read them from several places at once and merge (eventd TRMP §6.4).

3.21.5 Ordering of aggregates #

COUNT BY and GROUP … COUNT results are ordered by count descending. Ties are broken by the group key under the value ordering above — for a GROUP of several fields, each key in turn in the order the fields are named — then by the representative's encoded bytes.

GROUP … SUM, AVG, MIN and MAX results MUST be ordered by their group keys ascending under the value ordering above, comparing each key in the order the fields are named. This order applies before SKIP and TAKE. An explicit SORT overrides it; groups tied under that sort retain their group-key order.

TOP N BY is exactly COUNT BY with TAKE N applied after that ordering.

DISTINCT results are ordered by the distinct value under the value ordering, unless an explicit SORT overrides it.

3.22 Fields and Results

Peios / Peios technical reference / PSPU / Observability Interfaces

Every result record is a flat MessagePack map. There is no nesting in a result, in any mode.

Flatness is what makes one set of rules — for access control, for ordering, for grouping, for projection — apply uniformly to a header field, a payload field and a metric label alike. A nested result would need a path language, and a path language would need to be reproduced identically by every SD author and every client.

3.22.1 Event fields #

These names resolve to header fields, by the mapping of PGSS §6.4:

event.time, event.sequence, event.cpu, event.type, event.boot.guid, emitter.class, emitter.token.guid, emitter.true-token.guid, emitter.process.guid

Every other name resolves to a payload field.

3.22.1.1 Header names are reserved #

Header field names are reserved in the query language and in result maps. If a payload carries a value whose flattened path is a header field's name, the header wins.

The colliding payload value is stored unchanged, and is retrievable as part of the raw payload by whatever holds it, but it is not exposed through field resolution, SELECT, WHERE, aggregation, access control or result maps. If the payload carries a map at a header field's path, the map's entire subtree is suppressed in the same way.

Only the paths themselves are reserved. A payload field beside them, such as emitter.process.pid, resolves normally. An emitter MUST NOT write a value at a header path (PGSS §6.4).

Note

The header must win because header fields are the ones the kernel stamped and an emitter could not influence (PSPK §2). If a payload value could shadow emitter.process.guid, an emitter could choose what its own events appeared to come from, and every query filtering on identity would be answerable by the party being investigated.

3.22.1.2 Flattening #

Payload maps are flattened recursively, path segments joined with .: a payload {source: {name: "x"}} exposes the field source.name.

Each map key on a queryable path MUST be a MessagePack string matching:

[A-Za-z_][A-Za-z0-9_-]*

Note that . is not permitted in a segment, though it is permitted in an identifier generally (§3.19) — a key containing a dot could not be distinguished from a path through two maps.

A key that is not a string, contains ., or does not match the grammar is stored unchanged and is not queryable: it does not resolve, does not appear in a result map, and has no field identity for access control. An empty map produces no field at all.

Maps are containers; every non-map value, arrays included, is emitted at its flattened path (§3.17).

If two payload entries flatten to the same path, the first in MessagePack map order wins and later duplicates are suppressed.

3.22.2 Log fields #

timestamp, origin, is_error, message, boot_id, job_id

The set is closed. There are no payload fields and no flattening, and a collector MUST reject any other log field name as a parse error rather than resolving it to null. A log record has a fixed shape, so a name outside it is a mistake the collector can see — unlike an event payload field, which may legitimately be absent from a given record.

is_error is a boolean in the query language, and compares against true/false or against 1/0.

3.22.3 Metric fields #

timestamp, boot_id, name, type, value, overflow

Every other name resolves to a label. Ingestion refuses labels colliding with these six (§3.10), so the flat namespace is unambiguous by construction rather than by a precedence rule.

type is the series type as a string: "counter", "gauge" or "histogram".

overflow is an output-only boolean marker. It is present and true only when a histogram percentile cannot be located below the series' highest boundary (§3.25); otherwise it is absent. Predicates and label selectors therefore see it as null, like any other output not yet computed.

3.22.3.1 Types belong to the query mode #

Header aliases and GUID literal forms apply only where a name is a header in that query mode. A metric label named emitter.class remains a string label: "kacs" is not the event-header class number. Likewise, a metric label named job_id or an event payload field named boot_id does not acquire the log header's GUID type. Ordinary string comparison rules apply, including to bracket selectors and cross-type metric selectors. The actual GUID headers still accept braced and unbraced input (§3.17).

3.22.4 What a record contains #

Event records carry the header fields plus every non-suppressed flattened payload field, as top-level keys.

Log records carry the log fields.

Raw metric sample records carry timestamp, boot_id, name, type, value, and the series' labels as top-level keys. A percentile-overflow result additionally carries overflow.

Aggregated metric results carry name, type and value, plus labels when the result belongs to one label set. They carry boot_id only when the query restricted the samples to exactly one boot by a boot_id equality predicate, in which case the value is that boot ID; a result that could span boots omits it rather than picking one. An aggregate tainted by a percentile overflow additionally carries overflow (§3.25).

Aggregation results in event and log mode carry the group key fields and the aggregate output, with the fixed schemas of §3.23.

3.22.5 SELECT #

SELECT narrows a result record to the named fields. It is valid only for non-aggregating event and log queries.

A collector MUST reject SELECT combined with COUNT BY, TOP N BY, DISTINCT or GROUP, and MUST reject it in metric mode: all of those have fixed output schemas, and a clause that reshapes a fixed schema is a contradiction rather than a refinement.

SELECT is applied last, after every other phase (§3.23). It controls the shape of the output and nothing else: a field not selected is still available to WHERE, to SORT, and to grouping. Narrowing what is displayed MUST NOT narrow what is filtered on.

3.23 Event Queries

Peios / Peios technical reference / PSPU / Observability Interfaces

EVENTS [type_pattern] [clauses…]

3.23.1 The type pattern #

The primary selector is an optional event type pattern, placed immediately after EVENTS.

EVENTS kacs.audit.access.checked   -- exactly that type
EVENTS kacs.*                      -- every type beginning "kacs."
EVENTS *.rejected                  -- every type ending ".rejected"
EVENTS kacs.*.changed              -- kacs.audit.descriptor.changed, kacs.caap.policy.changed, …
EVENTS                             -- every type

* is the only metacharacter, and matches zero or more of any character, dots included. ?, [ and { have no special meaning and a collector MUST NOT treat them as any. Matching folds case, like every string comparison (§3.20).

A pattern with no * is exactly WHERE event.type == "…". A pattern whose only * is trailing is exactly WHERE event.type STARTS_WITH "…". Anything else is a glob.

3.23.2 Origin class aliases #

emitter.class, the origin class, accepts named aliases as well as its integer values:

AliasValue
userspace0
kmes1
kacs2
lcs3
EVENTS WHERE emitter.class == kacs SINCE 1h ago

These are the only aliased values in the language. A collector MUST accept both forms and MUST treat them as identical.

3.23.3 Aggregation #

Grouping equality, canonical representatives and tie ordering are defined in §3.21. Every aggregation below has a fixed output schema, and rejects SELECT (§3.22).

3.23.3.1 COUNT BY #

Counts records grouped by one field, ordered by count descending.

EVENTS SINCE 24h ago COUNT BY event.type

Output: {<field>: representative, count: <unsigned integer>}.

3.23.3.2 TOP N BY #

COUNT BY with a limit — the N most frequent values.

EVENTS SINCE 1h ago TOP 10 BY emitter.process.guid

Output: the COUNT BY schema.

3.23.3.3 DISTINCT #

The distinct values of one field.

EVENTS SINCE 24h ago DISTINCT event.type

Output: {<field>: representative}.

3.23.3.4 GROUP #

Groups by one or more fields, followed by an aggregation function: COUNT, or SUM, AVG, MIN, MAX with a field argument.

EVENTS SINCE 1h ago GROUP emitter.class COUNT
EVENTS SINCE 1h ago GROUP emitter.class, event.type COUNT
EVENTS SINCE 1h ago GROUP event.type AVG operation.duration

Output, for GROUP a, b:

QueryRecord
COUNT{a, b, count}
SUM x{a, b, sum}
AVG x{a, b, avg}
MIN x{a, b, min}
MAX x{a, b, max}

Group-key fields carry canonical representatives (§3.21).

3.23.3.5 What is aggregated #

For SUM, AVG, MIN and MAX, records whose field is null or non-numeric are excluded from the aggregate — not treated as zero. COUNT counts every record regardless. If no record in a group contributes a numeric value, the group's aggregate is null and the group is still present, because COUNT of it is still meaningful.

3.23.3.6 Result types #

  • COUNT returns an unsigned integer.
  • SUM over integers returns an integer when the exact mathematical sum fits in signed or unsigned 64 bits. If it does not, or if any input was a float, it returns a float64. If that would be non-finite, the query MUST fail with an error rather than returning an infinity.
  • AVG returns a float64 whenever at least one numeric value contributed.
  • MIN and MAX return the winning value itself, under exact numeric comparison. When an integer and a float tie, the integer wins.

3.23.4 Ordering #

Without SORT, results are ordered by event.time descending, ties broken as §3.21 requires.

3.23.5 INDEX #

EVENTS INDEX target_sid

INDEX asks the collector to prioritise a field for query acceleration immediately, rather than waiting for it to be observed often enough to be prioritised automatically. It exists for incident response, where the field that suddenly matters has never been queried before.

INDEX is an administrative operation, not a query. It returns no records. A collector MUST check the caller's token against a Security Descriptor governing administration of the collector — one distinct from the read-path descriptors of §3.28 — and MUST refuse a caller that does not hold it. A collector without such a descriptor MUST refuse INDEX outright.

A collector MAY treat INDEX as advisory and MAY decline the request, shed the acceleration later, or do nothing at all. It is a hint about priority; the accelerations a collector maintains are its own business, and a conforming collector that maintains none accepts INDEX and has nothing to do.

There is no command to undo it, because there is nothing to undo: a collector reconsiders its own accelerations continuously and the hint decays with disuse.

Note

The right to issue INDEX MUST NOT be the read right. Accelerating a field costs write throughput on every record thereafter, so INDEX is a way for a caller to degrade the system for everyone, and a caller permitted only to read data has not been permitted to do that.

3.24 Log Queries

Peios / Peios technical reference / PSPU / Observability Interfaces

LOGS [FROM origin[, origin…]] [ERROR ONLY] [CONTAINING "text"] [clauses…]

Log mode has three primary selectors rather than one, all optional and all combinable. Each is sugar for a WHERE predicate, and each exists because it is the thing a person actually types.

3.24.1 FROM #

Selects by origin. Several may be listed, comma-separated.

LOGS FROM loregd
LOGS FROM loregd, peinit
LOGS

FROM is exactly WHERE origin == "…" for one origin and WHERE origin IN ("…", "…") for several.

Origins are written as identifiers (§3.19) or as quoted strings. An origin that names a producer within a service, such as jellyfin/HealthCheck or jobs/<guid>, is not an identifier and is written quoted (§3.7):

LOGS FROM "jellyfin/ExecStartPre[0]"

3.24.2 ERROR ONLY #

Selects lines that came from standard error.

LOGS ERROR ONLY
LOGS FROM loregd SINCE 1h ago ERROR ONLY

It is exactly WHERE is_error == true, and like every clause it may appear anywhere after LOGS without changing the meaning (§3.18).

3.24.3 CONTAINING #

Selects lines whose message contains the given text — a substring match, folding case like every string comparison (§3.20).

LOGS CONTAINING "connection refused"
LOGS FROM loregd CONTAINING "failed to open"

It is exactly WHERE message CONTAINS "…".

CONTAINING is a log-specific keyword because searching text is the primary operation on log data, and the primary operation deserves the shortest spelling. It is a substring scan, not an indexed text search: a collector MUST NOT restrict what it matches, and combining it with SINCE is what keeps it affordable.

3.24.4 Projection and aggregation #

SELECT narrows non-aggregating results to named log fields, and is additive across clauses (§3.22).

COUNT BY, TOP N BY, DISTINCT and GROUP work exactly as in event mode (§3.23), with the same fixed output schemas, the same result types, and the same prohibition on combining them with SELECT.

LOGS SINCE 1h ago COUNT BY origin
LOGS SINCE 1h ago TOP 5 BY origin

3.24.5 Ordering #

Without SORT, results are ordered by timestamp descending, ties broken as §3.21 requires.

3.24.6 No payload fields #

Log mode has a closed field set (§3.22). A collector MUST reject an unknown log field name as a parse error, in a WHERE, a SORT, a SELECT or a grouping clause alike.

This differs from event mode, where an unknown name is a payload field that resolves to null. The difference is that a log record's shape is fixed and known: a name outside it cannot be a field that this record happens to lack, so treating it as null would answer a question the client did not ask.

3.25 Metric Queries

Peios / Peios technical reference / PSPU / Observability Interfaces

METRIC name[label_selector] [transform] [aggregation] [clauses…]

Metric mode evaluates rather than searches. A collector MUST reject SELECT in metric mode: the result schemas are fixed (§3.22).

3.25.1 Selecting series #

The primary selector is a metric name, optionally followed by a label selector in brackets. The name supports * with the same glob semantics as an event type pattern (§3.23).

METRIC cpu.usage
METRIC cpu.*

The brackets — present, absent, or present and empty — decide how multiple matching series are handled, and this is the distinction that governs the rest of the mode.

No brackets — aggregate. Every matching series is combined into one result.

METRIC cpu.usage                    -- average across all cores
METRIC cpu.usage MAX                -- maximum across all cores

Empty brackets — break out. Each series is returned separately.

METRIC cpu.usage[]                  -- latest value per core
METRIC cpu.usage[] SINCE 1h ago     -- a time series per core

Filled brackets — select. Only series matching the label predicates.

METRIC cpu.usage[core="0"]
METRIC cpu.usage[core="0", host="srv1"]
METRIC disk.usage[device STARTS_WITH "sd"]

Label predicates are comma-separated and combined with AND. They use the operators of §3.20, and within a label selector = is accepted as equality alongside ==. Label keys are identifiers; values are identifiers or quoted strings. An absent label resolves to null, so [device IS NULL] selects the series that carry no device label.

3.25.2 Homogeneity #

After the name, the label selector, WHERE predicates and access filtering have been applied, the remaining series MUST be of one type. A collector MUST reject a selection spanning more than one type at execution time, with an error asking for a narrower name or an explicit WHERE type == ….

A selection resolving to zero series returns no records — a successful query with an empty result, not an error.

The rule exists because every function below is defined on one type. A selection mixing counters and gauges has no meaningful rate, and a selection mixing either with histograms has no meaningful value at all.

3.25.3 Function stages #

Function keywords execute in fixed stages regardless of where they were written:

  1. Transform — RATE, DELTA, P50, P95 or P99. Operates within each series independently and produces scalars. At most one per query.
  2. Terminal aggregation — either a scalar aggregation (AVG, MIN, MAX, SUM) or a window aggregation (AVG_OVER, MIN_OVER, MAX_OVER, SUM_OVER). At most one per query; specifying both a scalar and a window aggregation is a parse error.

The pipeline ordinarily operates on scalars throughout. Counter and gauge samples are already scalar; a histogram sample is not scalar until a percentile function has been applied. The sole exceptional result is a tagged percentile overflow, whose propagation is defined below. A collector MUST therefore reject, at execution time when the type is known, a query that resolves to a histogram series without a percentile function, or that applies RATE, DELTA, or any scalar or window aggregation directly to one.

Every numeric output is a finite binary64. If any computation would produce NaN or an infinity, the query MUST fail with an error rather than returning it. The null in a tagged percentile-overflow result is not a numeric output.

3.25.4 Transforms #

3.25.4.1 RATE and DELTA #

DELTA is the change between consecutive samples; RATE is that change per second. Both apply only to counter series, and a collector MUST reject them on a gauge or histogram at execution time.

Both use the same pair construction. Samples of one series are taken in ascending timestamp order, with a deterministic tiebreaker among samples sharing a timestamp. Each consecutive pair (s1, s2) whose s2 falls inside the effective query range, and where s2 is later than s1, produces one scalar at s2's timestamp. The immediately preceding sample before the first in-range one MUST be used as s1 for the first pair, when such a sample exists — without it the first point of every range would be missing, and a chart would show a notch at the start of every window.

The adjusted delta is s2 - s1 when the value rose, and s2 alone when it fell, because a fall means the counter restarted from zero. RATE is that adjusted delta divided by the elapsed seconds. A pair with non-positive elapsed time contributes nothing.

METRIC http.requests.total SINCE 1h ago RATE
METRIC http.requests.total SINCE 1h ago DELTA

3.25.4.2 P50, P95, P99 #

Percentiles of histogram series only; a collector MUST reject them on a counter or gauge at execution time. Each histogram sample yields one value.

Evaluation is nearest-rank over the sample's cumulative counts: for percentile q, compute rank = ceil(q × total_count), and take the first boundary whose cumulative count is at least rank.

A sample with total_count == 0 yields no record. A sample whose rank falls above the final cumulative count — meaning the percentile lies in the overflow region above the highest boundary — yields a record with value null and overflow true. The distribution proves that the percentile exceeds the highest boundary but does not record a finite value for it. overflow is absent from ordinary percentile results.

METRIC request.duration P95
METRIC request.duration[origin="loregd"] SINCE 1h ago P99

Note

The overflow marker is why a producer's highest bucket boundary still matters (§3.10). It prevents a P99 above that boundary from looking like no data, but it cannot recover the value the histogram did not locate. An operator seeing overflow: true is seeing a mis-provisioned histogram.

3.25.5 Scalar aggregations #

AVG, MIN, MAX and SUM reduce scalars to one value. They MUST NOT be applied to a histogram series directly.

What they aggregate over depends on the brackets:

  • Bracketed, so one result per series: over time, within each series.
  • Unbracketed without SINCE: over the latest transformed value of each matching series. The result timestamp is the greatest of the contributing timestamps. A series that cannot produce a value — a RATE with fewer than two samples, say — contributes nothing.
  • Unbracketed with SINCE: valid only when the selector resolves to zero or one series. More than one MUST be rejected with an error asking for a window aggregation.
METRIC cpu.usage AVG
METRIC http.requests.total RATE SUM
METRIC cpu.usage[] SINCE 1d ago AVG
METRIC cpu.usage[core="0"] SINCE 1h ago MIN

The unbracketed default aggregation, when no SINCE and no explicit function is given, is AVG. No implicit scalar aggregation is added when a window aggregation is present.

If nothing contributes to an aggregation, the query returns no record for that output group. Otherwise the output timestamp is the greatest contributing timestamp — for RATE and DELTA, the later sample of the contributing pair.

If any contributing percentile result has overflow true, a scalar aggregation MUST conservatively return value null and overflow true. It MUST NOT ignore the result and compute from the remaining finite values.

3.25.5.1 Why unbracketed plus SINCE needs a window #

A collector MUST NOT synthesise a merged time series from samples that do not share timestamps.

Two series sampled at unrelated moments cannot be averaged point by point without inventing values between the points, and interpolation would make the collector responsible for a number nobody measured. A window aggregation supplies the common time grid explicitly, which is why it is required rather than assumed.

3.25.6 Window aggregations #

AVG_OVER, MIN_OVER, MAX_OVER and SUM_OVER take a duration and produce one value per window. They require SINCE; a collector MUST reject a window aggregation without one as a parse error.

Windows are fixed and aligned to Unix-epoch multiples of the duration — not to the query's start — so that the same window boundaries fall in the same places for every query. The output timestamp is the window start. Windows with nothing in them are omitted rather than emitted as null.

METRIC cpu.usage SINCE 1d ago AVG_OVER 1h
METRIC cpu.usage[] SINCE 1d ago AVG_OVER 5m
METRIC http.requests.total SINCE 1h ago RATE SUM_OVER 5m
METRIC request.duration P95 SINCE 1h ago AVG_OVER 5m

AVG and AVG_OVER are different keywords and a collector MUST NOT treat them as synonyms: AVG produces one value for the range, AVG_OVER one per window.

A collector MAY cache aligned, complete window results as an implementation optimisation. Such a cache MUST be transparent: freshness must be proved from the authoritative samples before reuse, partial windows must retain their ordinary semantics, and a missing, stale or disabled cache MUST produce exactly the same result as direct evaluation.

For raw and percentile-transformed values, a window contains the scalars whose timestamps fall inside it, and the function is applied to those. No interpolation is performed.

If a window contains a percentile result with overflow true, its output MUST also carry value null and overflow true. This propagation applies again when per-series window results are combined.

For RATE and DELTA with a window aggregation, each series first produces at most one scalar per window: the window DELTA is the sum of reset-adjusted deltas for pairs whose later sample is in the window, and the window RATE is that divided by the elapsed seconds those pairs covered. The preceding-sample rule applies to the first pair of each window. The terminal aggregation then combines the per-series window values — so RATE SUM_OVER 5m sums the series' five-minute rates, and RATE AVG_OVER 5m averages them. Where the selector resolves to exactly one series, all four window functions return that series' window value.

Bracketed window queries keep labels in the result rows. Unbracketed ones omit them, unless the selector resolved to exactly one series.

3.25.7 Without SINCE #

With no SINCE, the query returns the latest value.

METRIC cpu.usage[core="0"]
METRIC cpu.usage[]
METRIC cpu.usage

"Latest" is per series, by timestamp with the deterministic tiebreaker. For RATE and DELTA, it is the latest valid consecutive pair with positive elapsed time; a series with no such pair returns nothing.

3.25.8 Boot filtering #

Samples carry boot_id but series continue across boots (§3.13). A query MAY restrict to one boot:

METRIC cpu.usage[] WHERE boot_id == "550e8400-e29b-41d4-a716-446655440000"

A boot-filtered metric query MUST be evaluated from raw samples. A collector MUST NOT serve one from any pre-computed aggregate that is not itself partitioned by boot.

3.25.9 Results #

One record per raw sample; one per valid pair for RATE and DELTA, timestamped at the later sample; one per non-empty histogram sample after a percentile transform, including an explicit overflow record; one per window for window aggregations; one for a scalar aggregation.

A finite histogram result carries only the percentile in value. An overflow result carries value null and overflow true. The boundaries, counts, total and sum are not returned by the query language in this revision, in any mode.

{timestamp: 1714000000000000000, boot_id: "{550e8400-…}", name: "cpu.usage", type: "gauge", core: "0", value: 42.7}
{timestamp: 1714000300000000000, name: "cpu.usage", type: "gauge", core: "0", value: 39.8}

Without SORT, metric results are ordered by timestamp ascending (§3.21) — the opposite of events and logs, because a metric result is read as a series rather than as a list of occurrences.

3.26 Cross-Type Filtering

Peios / Peios technical reference / PSPU / Observability Interfaces

A cross-type filter narrows one data type by a condition on another. It is the only correlation mechanism in the language; there is no join.

EVENTS kacs.* SINCE 1h ago WHERE METRIC cpu.usage[core="0"] > 80
LOGS FROM loregd SINCE 1h ago WHERE EVENT kacs.audit.access.checked EXISTS
METRIC cpu.usage[] SINCE 1h ago WHERE EVENT eventd.store.quarantined EXISTS
EVENTS kacs.* SINCE 1h ago WHERE LOG loregd CONTAINING "error" EXISTS
FormAvailable in
WHERE METRIC …events, logs
WHERE EVENT … EXISTSlogs, metrics
WHERE LOG … EXISTSevents, metrics

3.26.1 How it is evaluated #

A collector MUST evaluate the cross-type condition first, producing the set of time ranges over which it holds, and then apply those ranges as additional timestamp bounds on the primary source.

The condition is evaluated against the referenced data's own resolution — the metric's sample interval, or the density of matching events — and not once per record of the primary source. It is computed once for the query.

3.26.2 Metric conditions #

WHERE METRIC operates on raw scalar samples of counter and gauge series only. Transform, scalar aggregation and window aggregation keywords are not valid in one, and a condition resolving to a histogram series MUST be rejected.

The selector MUST resolve to zero or one series. Zero produces no true ranges. More than one MUST be rejected with an error asking for a bracketed or narrower selector.

Within the effective query range, a sample's value is treated as active over [sample.timestamp, next_sample.timestamp), clipped to the range, and the final sample stays active through the upper bound. A collector MUST include the latest sample before SINCE as the initial state when one exists; without it the condition would be false from the start of every range until the first sample inside it, which for a fifteen-second sampling interval is fifteen seconds of wrongly excluded records. If no earlier sample exists, the condition is false until the first in-range sample.

Samples sharing a timestamp are ordered deterministically; the earlier ones create zero-width intervals and the last at that timestamp is the active value.

This is interpolation of a kind, and it should be understood as such: it assumes the condition held continuously between two samples. A metric that crossed a threshold and crossed back between samples is invisible.

3.26.3 Existence conditions #

WHERE EVENT … EXISTS and WHERE LOG … EXISTS are true when at least one matching record lies near the primary record in time. The event type supports * globbing (§3.23); the log form names an origin and optionally a CONTAINING text.

"Near" is a centred half-open window of a configured width W. With lower = floor(W / 2) and upper = W - lower, the condition is true for a primary timestamp t when a matching record exists with:

timestamp >= t - lower
timestamp <  t + upper

Equivalently, a matching record at e contributes the true range [e - lower, e + upper). When W is odd the extra nanosecond falls on the upper side, so the width is exactly W and never W ± 1.

Note

§3.A gives the mainline width of the existence window and its adjustable range, alongside every other bound in this chapter.

3.26.4 The lookback limit #

A collector MUST bound how far back a cross-type filter may scan.

Note

§3.A gives the mainline value and adjustable range for this bound and every other in this chapter.

If the effective query range exceeds the limit, a collector MUST reject the cross-type filter with an error saying the range is too large, and the error SHOULD suggest narrowing it with SINCE or UNTIL.

A query with a cross-type filter and no SINCE MUST be rejected. An unbounded cross-type scan is never permitted, in any mode, at any configured limit.

The reason is that a cross-type filter reads a second store in full before the first query begins. Its cost is set by the referenced data's density, which the client did not select and cannot see, so a query that looks cheap can scan a hundred times more than it returns.

3.26.5 Cost #

A cross-type filter is efficient when it is selective — narrow true ranges eliminating most of the primary source — and expensive when it is broadly true, which is the case where it also eliminates nothing. A condition that holds across the whole range costs the full scan of both stores and returns exactly what the query would have returned without it.

3.27 Streaming

Peios / Peios technical reference / PSPU / Observability Interfaces

STREAM turns a query into a live tail. It is a flag, may appear anywhere in the string, and takes no argument.

Streaming is available for event and log queries only. A collector MUST reject STREAM in metric mode.

Note

Metric streaming is absent because metric data is sampled on an interval — typically fifteen seconds — and the consumer is a dashboard that polls. Streaming individual samples adds a delivery path with a latency budget far finer than the data it carries.

3.27.1 The shape of a streaming query #

  1. The collector executes the query normally and sends the initial result set as "ok" messages.
  2. It sends "watch" (§3.16). The query is established at this point and not before.
  3. It stays open. As records are committed, it evaluates them against the query and sends those that match.
  4. It continues until the client disconnects, an error terminates it, or the collector shuts down.

There is no "end" message for a streaming query, ever.

3.27.2 What may be streamed #

Raw record queries and DISTINCT queries. A collector MUST reject STREAM combined with COUNT BY, TOP N BY or GROUP as a parse error — those produce one answer about a set, and a set that is still growing has no answer yet.

A collector MUST reject STREAM combined with UNTIL. An upper time bound and an unbounded live tail are contradictory requests.

SINCE is permitted and applies to both phases, resolved against the evaluation time captured at query start (§3.19).

3.27.3 What still applies during the watch phase #

Access control, the primary selector, the SINCE bound and every WHERE predicate — cross-type conditions included — are evaluated against each new record.

SORT, TAKE and SKIP apply to the initial result set only. Streamed records are delivered in commit order and a collector MUST NOT reorder, limit or skip them: there is no total order over records that have not arrived, and applying TAKE to a stream would silently end it.

SELECT applies to streamed records as it does to initial ones.

3.27.4 DISTINCT streaming #

EVENTS kacs.* DISTINCT emitter.process.guid STREAM
LOGS DISTINCT origin STREAM

A DISTINCT stream emits a value the first time it is seen, and never again. The output schema is DISTINCT's fixed one (§3.23) in both phases.

The initial result set is the complete distinct set visible at query start, after access control and every filter. The collector then holds a seen set initialised from it. Each newly committed record that passes access control and the filters is reduced to its value for the field, and emitted only if that value is not already in the seen set under the grouping equality of §3.21; emitted values are then added.

A collector MUST bound the seen set.

Note

§3.A gives the mainline value and adjustable range for this bound and every other in this chapter.

If initialising the set or inserting a value would exceed the bound, the collector MUST terminate the query with an error. It MUST NOT evict: "not seen before" is the entire meaning of the output, and a set that forgets would re-emit values it had already reported, which is worse than stopping.

A collector MUST reject DISTINCT … STREAM combined with SORT, TAKE or SKIP, so that the seen set always corresponds to the complete initial visible set. SELECT is already invalid with DISTINCT (§3.22).

3.27.5 Cross-type conditions during the watch phase #

The pre-computed time ranges of §3.26 describe the past. A collector MUST NOT reuse them for streamed records.

For a metric condition, the selector has already been required to resolve to exactly one series (§3.26). For each committed batch, the collector finds that series' active sample at the batch's latest candidate timestamp under §3.26's interval rules and evaluates the condition against it. If no sample is active there, the condition is false. A false condition filters out the whole batch; a true one leaves the batch to be filtered by the remaining predicates as usual.

For an existence condition, the collector applies §3.26's centred window to each candidate record's own timestamp. These are evaluated per record, not per batch, because a matching record may be near some of a batch and not the rest.

Note

Evaluating the metric condition once per batch rather than once per record is an approximation, and an intentional one. A commit batch spans a fraction of a second while a metric sample spans fifteen, so every record in a batch normally maps to the same sample. At sub-second metric resolution it filters more coarsely than per-record evaluation would, and records near a threshold crossing are included or excluded as a group.

3.27.6 Backpressure #

If a client cannot keep up, the collector MUST drop the query rather than buffer for it.

Backpressure is detected on the socket send buffer: when a result message cannot be sent because the buffer is full, the collector MUST terminate the query immediately and MUST NOT block on the send. It sends an error if the socket will still take one, and closes otherwise.

Streaming MUST NOT slow or block ingestion. A streaming client is the lowest-priority consumer of a collector's time, and a slow one is disconnected rather than accommodated — the same principle as §3.4, applied on the way out.

3.27.7 Latency #

Delivery latency is bounded below by the collector's commit interval for the store concerned, because a record is only streamable once it is committed. A client that needs lower latency than that is not served by this interface: the KMES ring buffer is the lower-latency path and is specified in PSPK.

Note

Streaming is a convenience for interactive tailing and dashboards. Where results must be predictable — cross-type conditions evaluated per batch, a metric threshold sampled coarsely, a disconnect under load — repeated non-streaming queries with a sliding SINCE are more reliable, and for latency-critical consumption the ring buffer bypasses a collector entirely.

3.28 What a Client Cannot See

Peios / Peios technical reference / PSPU / Observability Interfaces

A collector MUST enforce read access on every query, against the token captured when the client connected (§3.14).

How it does so is its own design, and the mechanism the mainline collector uses is described in the eventd TRMP. What this chapter fixes is the part a client can observe: which results it gets, and what it is told about the ones it does not.

3.28.1 The unit of access is the concrete identifier #

Access is resolved per concrete identifier — the event type, log origin or metric name a stored record actually carries (§3.2) — and not per query, per store, or per pattern the query happened to write.

A collector MUST resolve each identifier that a query's data could touch independently. A broad selector authorizes nothing by itself: EVENTS with no pattern, EVENTS kacs.*, LOGS with no FROM, and METRIC cpu.* are all resolved identifier by identifier, and a client permitted to read one matching identifier and not another sees only the first.

Identifiers are matched to rules by dot-delimited prefix, most specific first, falling back to a wildcard default: for kacs.audit.access.checked, a rule for kacs.audit.access.checked, then one for kacs.audit.access, then kacs.audit, then kacs, then the default.

A collector MUST fail closed. If no rule resolves — including because the default is missing — access is denied.

3.28.2 Filtering is silent #

Records and fields removed by access control are removed without comment. A collector MUST NOT indicate in a response that anything was withheld, and a client MUST NOT assume a result set is complete.

The consequences are precise and a client needs all of them:

  • A record whose identifier the client may not read is absent, not redacted.
  • A field the client may not read is absent from the record, and is indistinguishable from a field the record never carried (§3.17).
  • COUNT, COUNT BY, TOP N BY, DISTINCT and every aggregation reflect only authorized records. A count is a count of what the client may see.
  • A cross-type condition referencing data the client may not read evaluates as though no matching data exists (§3.26). It does not fail the query.
  • TAKE and SKIP page over the authorized records only.

3.28.3 Access control runs before everything #

A collector MUST remove unauthorized records from the logical row set before predicates, transforms, grouping, aggregation, sorting, pagination and projection (§3.18).

This is not tidiness. Counting, ordering or paginating over records a client may not read leaks them through the count, through the ordering, and through the gaps in pagination — a client could establish how many records of a type it cannot read exist, and roughly when, without ever seeing one.

A collector MAY reach the result however it likes: pushing the authorization down into its storage engine, or reading candidates and discarding them before aggregating. What it MUST NOT do is produce a different answer from the one filtering-first produces.

3.28.4 Denied fields do not fail the query #

When a query references a field in a predicate, a grouping, a sort or an aggregation, and some matching identifier does not grant that field, the records under that identifier contribute nothing — exactly as if their identifier had been denied outright.

A collector MUST NOT reject the query.

Note

Rejecting would be the more informative behaviour and that is precisely the objection to it. A rejection tells the client that an identifier exists, matches its query, and carries a field it may not read — three facts about data it was not permitted to see, delivered by the mechanism meant to withhold them. A client could enumerate restricted event types by watching which queries are refused. Silence costs the client a result that is narrower than it looks; rejection costs the system the property the whole model rests on.

Authorization for a field is resolved from the field as written, against each concrete identifier, and does not depend on whether any record of that identifier actually carries it. Payload fields vary between records of the same type, so a rule that turned on presence would be undecidable before the scan it was meant to authorize.

3.28.5 What is not a field #

Derived aggregate outputs — count, sum, avg, min, max — are not source fields, have no access identity of their own, and are visible whenever the client is authorized for the records and the source fields they were computed from.

Values internal to preserving query semantics — row identifiers, series identifiers, ordering tiebreakers, series type checks — are likewise not query-language fields (§3.21). A metric result's value is a source field, because it is a raw sample or a scalar derived from raw samples.

3.28.6 Errors say nothing #

A collector MUST NOT include a value the client is not authorized to read in any error message (§3.16), including in errors raised by internal consistency checks.

3.28.7 Streaming #

Access decisions made for the initial result set are reused during the watch phase, but a collector MUST resolve any new concrete identifier that appears in a streamed batch and check it before using the record or its distinct value — a new event type or a new log origin appearing mid-stream has never been authorized.

If a rule changes during a streaming query, a collector MUST re-check subsequent batches against the new rule.

The token does not change. It was captured at connection (§3.14), so a client whose group memberships change mid-stream continues to be evaluated against what it connected with, and a client whose access is revoked keeps receiving records until it disconnects.

3.28.8 What write authentication proves #

Log origin is broker-attested. The service manager chooses it from the pipe being read, and the log socket excludes service processes (§3.6). It proves which service context the manager associated with the line; it does not prove that the line's text is true.

Metric name is publisher-authorized. KACS attests the effective token that sent the datagram and the collector verifies EVENTD_PUBLISH for that name (§3.9). Labels and values remain producer statements. A publisher authorized for http.requests can report any value and any valid label set for that name.

Read authorization is independent. Permission to publish an identifier does not imply permission to query it, and permission to query it does not imply permission to publish it.

3.29 Extension

Peios / Peios technical reference / PSPU / Observability Interfaces

There is no version number on any of the three interfaces. No datagram carries one, no query message carries one, and there is no exchange in which either party could state or discover what the other speaks.

That is a deliberate consequence of the shapes chosen, and it is worth being explicit about, because it means every rule below is the only mechanism available.

Ingestion is one-way over a datagram socket: there is no reply in which a collector could announce a version and no state in which a producer could remember one. The query channel could carry a version — it is a stream, and it has a request message — and does not, because a version field is only useful if a party may then behave differently, and a client cannot usefully vary: it either asks a question the collector understands or does not.

What replaces negotiation is a set of rules under which both sides may change without either being told.

3.29.1 Unknown fields are ignored #

A collector MUST ignore fields it does not recognise in a log record (§3.7), in a metric record (§3.11), and in a query request (§3.15).

This is what allows a field to be added. A producer built against a later revision may send a field this collector has never heard of, and the record is still stored; a producer built against an earlier one omits a field that has since been added, and the record is still stored because everything added is optional.

A field added to any of these three maps MUST therefore be optional, and a collector MUST NOT require one to be present.

3.29.2 Unknown values are refused, not ignored #

The rule does not extend to values.

An unrecognised type in a metric record discards the record (§3.12); a first token that is not a mode fails the query (§3.18); an unrecognised keyword is a parse error. A collector MUST NOT guess at an unrecognised value, and MUST NOT skip a field it recognised but could not interpret.

The asymmetry is the point. An unknown field is something the sender knows about and this collector does not, and ignoring it loses only what was never understood. An unknown value in a known field is the sender saying something specific about this record, and proceeding without understanding it stores something other than what was sent.

3.29.3 Response statuses #

A client MUST treat a response whose status it does not recognise as an error terminating the query, and MUST discard the "ok" messages it has received for that query unless "end" or "watch" had already arrived (§3.16).

A status is the control flow of the response stream, so there is no ignoring one: a client that skipped an unknown status would be waiting for a terminal message that had already been sent, or treating an incomplete result as complete. Failing is the only safe reading.

A collector MUST NOT introduce a new status for a condition that the four existing ones can express.

3.29.4 What may change without notice #

  • New optional fields in a log record, a metric record or a query request.
  • New fields in result records. A client MUST tolerate a key it does not recognise, and MUST NOT reject a record for carrying one.
  • New query keywords, clauses and functions. A client sending one the collector does not know receives a parse error, which is the correct answer.
  • Wording of any error string (§3.16).
  • New event types, log origins and metric names. These are data, not interface; nothing enumerates the valid set of any of them.

3.29.5 What may not change #

  • The meaning of an existing field, in either direction. A field is added or it is left alone.
  • The type of an existing field.
  • The four response statuses, or the rule that exactly one terminal message ends a query.
  • The framing of §3.15, which has no version field and therefore no way to change compatibly.
  • A required field becoming optional, or an optional one becoming required.

3.29.6 Limits are not the interface #

The declared bounds — the datagram ceilings above their portable floor (§3.6, §3.9), the query request ceiling and response target (§3.15), the concurrency and timeout bounds (§3.14, §3.16), the existence window and lookback limit (§3.26) — are configuration, and an administrator may change any of them within its stated range.

A collector MUST behave identically at any value in its supported range. A producer or client MUST NOT infer a bound from having exceeded one, or from not having exceeded one, and MUST NOT depend on the mainline defaults quoted in this chapter.

The 262144-byte log and metric datagram floor is different: it is part of the interface. A collector may raise its declared ceiling but MUST NOT lower it below that floor, and a portable producer does not discover or depend on the raised value (§3.6).

3.30 Conformance

Peios / Peios technical reference / PSPU / Observability Interfaces

A conforming implementation of any role MUST satisfy every requirement in this chapter. This section collects the obligations that are not tied to one message.

3.30.1 A collector #

Serve three separate channels. Two SOCK_DGRAM for ingestion, one SOCK_STREAM for queries, each on its own socket, each protected by a Security Descriptor established before it accepts anything (§3.3).

Never exert backpressure. No producer stalls because of a collector, under any load, in any failure state (§3.4).

Never react to input. No event, no log entry, no client-observable counter, in response to a malformed, unwanted or excessive submission (§3.4).

Authenticate ingestion. Admit only the service-manager broker on the log socket; require a complete KACS token on every metric datagram and authorize every metric name for EVENTD_PUBLISH (§3.3, §3.6, §3.9).

Validate at the stated scope. Datagram, record, or field — as §3.8 and §3.12 set out, and no more broadly. In particular a malformed record MUST NOT cost the valid records batched with it, and a malformed optional field MUST NOT cost a log record.

Store what you were given. A log message byte-for-byte, an event payload unmodified, a timestamp uncorrected, a histogram's boundaries in the order sent (§3.8, §3.10, §3.5).

Preserve gaps. No interpolation, no backfill, no synthesised sample (§3.13).

Identify every query client from the connection, before executing anything, and refuse the query if you cannot (§3.14).

Order totally and deterministically. Every result, for a fixed set of stored records, in one order — so that SKIP and TAKE mean something (§3.21).

Use query-language semantics, not your storage engine's, for every comparison, ordering, grouping and equality test the language defines (§3.20, §3.21).

Enforce access before you compute, per concrete identifier, failing closed, and silently (§3.28).

Bound everything a client can consume: concurrent queries, streaming queries, message size, query time, distinct-stream values, cross-type lookback (§3.14, §3.15, §3.16, §3.26, §3.27).

Behave identically across your configured ranges (§3.29).

3.30.2 A producer #

Send well-formed records and accept that malformed ones vanish without notice (§3.8, §3.12).

Stay within the 262144-byte portable datagram ceiling, batched or not. Do not add discovery or depend on a collector's larger local setting (§3.6).

Choose a stable, conforming metric name. It must match the identifier grammar, name the producer distinguishably and use dots for hierarchy, because publication and read rules are written against it and queries select on it (§3.10).

Convey metric identity. Enable KACS_SO_PASS_TOKEN before sending metrics and retain the sending socket so the captured-token and authorization caches remain effective (§3.9).

Timestamp at production, not at submission (§3.7).

Bound your label cardinality, and keep histogram boundaries fixed for the life of a metric (§3.10, §3.13).

Never assume delivery. No acknowledgement exists, none is coming, and a record that mattered should have been an event (§3.4).

Never change a metric's type. Doing so ends the series silently and permanently (§3.10).

3.30.3 A client #

Tolerate unknown keys in result records, and unknown statuses as errors (§3.29).

Discard partial results. An error before "end" or "watch" means every "ok" message for that query is void (§3.16).

Assume nothing about completeness. Results are silently filtered by access, counts count only what you may see, and an absent field is indistinguishable from a denied one (§3.28).

Interpret provenance narrowly. A log origin is broker-attested and a metric name is publisher-authorized; neither proves the message, value or labels are truthful (§3.28).

Do not parse error strings (§3.16).

Open one connection per query (§3.14).

3.30.4 What this chapter does not require of a collector #

A conforming collector need not accelerate anything, pre-compute anything, shard anything, or retain anything for any particular period. It need not honour INDEX beyond accepting it (§3.23). Its storage, indexing, retention and query planning are entirely its own, and every requirement above is stated about the answer rather than about how the answer is reached.

Appendix 3.A Limits

Peios / Peios technical reference / PSPU / Observability Interfaces

Every bound this chapter requires a collector to enforce, with the value and adjustable range of the mainline collector. The mainline values are informative: a conforming collector chooses its own, and a producer or client MUST NOT depend on any of them (§3.29).

The mainline configuration key names are those of eventd, whose configuration is catalogued in the eventd TRMP §A.

3.A.1 Ingestion #

BoundMainline valueMainline rangeKeySection
Log datagram ceiling262144 B262144 – 1048576MaxLogDatagramBytes§3.6
Metric datagram ceiling262144 B262144 – 1048576MaxMetricDatagramBytes§3.9
Receive queue, either socket≤ 4 × the ceiling——§3.6

3.A.2 Queries #

BoundMainline valueMainline rangeKeySection
Query request ceiling65536 B1024 – 16777216MaxQueryRequestBytes§3.15
Query response target65536 B1024 – 16777216QueryResponseTargetBytes§3.15
Query timeout30000 ms1000 – 300000QueryTimeoutMs§3.16
Concurrent queries1281 – 4096MaxConcurrentQueries§3.14
Concurrent streaming queries641 – 1024MaxStreamingQueries§3.14
Concurrent queries per caller161 – 4096MaxQueriesPerUser§3.14
Memory all running queries hold together268435456 B16777216 – 4294967295MaxQueryHeldBytes§3.16
Values per DISTINCT stream1000001000 – 10000000MaxDistinctStreamValues§3.27

3.A.3 Cross-type filtering #

BoundMainline valueMainline rangeKeySection
Existence window W15000 ms1000 – 300000CrossTypeWindowMs§3.26
Maximum lookback604800 s3600 – 2592000CrossTypeMaxLookbackSeconds§3.26

3.A.4 Fixed by this chapter #

These are not configuration and a collector MUST NOT vary them.

QuantityValueSection
Timestamp domain0 – 9223372036854775807 ns§3.5
GUID field width16 bytes§3.7, §3.11
Message length prefix4 bytes, little-endian§3.15
Maximum response payload2^32 - 1 bytes§3.15
Transforms per queryat most 1§3.25
Terminal aggregations per queryat most 1§3.25
Queries per connectionexactly 1§3.14

The response target is not a ceiling. A complete record larger than the target travels alone, so the configurable ingestion ceilings do not need to be coupled to it (§3.15).

Appendix 3.B Query Language Reference

Peios / Peios technical reference / PSPU / Observability Interfaces

An index of the language, and of where each construct is valid. The normative definitions are in §3.18 to §3.27; nothing here adds a rule.

3.B.1 Shape #

EVENTS [type_pattern]                             [clauses…]
LOGS   [FROM o[, o…]] [ERROR ONLY] [CONTAINING s] [clauses…]
METRIC name[label_selector] [transform] [aggregation] [clauses…]

3.B.2 Clause validity #

ClauseEVENTSLOGSMETRICSection
SINCE / UNTILyesyesyes§3.19
WHEREyesyesyes§3.20
WHERE METRICyesyesno§3.26
WHERE EVENT … EXISTSnoyesyes§3.26
WHERE LOG … EXISTSyesnoyes§3.26
SORTyesyesyes§3.21
TAKE / SKIPyesyesyes§3.18
SELECTnon-aggregating onlynon-aggregating onlyno§3.22
COUNT BY / TOP N BYyesyesno§3.23
DISTINCTyesyesno§3.23
GROUP + functionyesyesno§3.23
STREAMyesyesno§3.27
ERROR ONLY / CONTAININGnoyesno§3.24
INDEXyesnono§3.23

WHERE and SELECT are the only repeatable clauses (§3.18).

3.B.3 Combinations that are rejected #

CombinationRejected atSection
SELECT with COUNT BY, TOP N BY, DISTINCT or GROUPparse§3.22
SELECT in metric modeparse§3.22
STREAM with COUNT BY, TOP N BY or GROUPparse§3.27
STREAM with UNTILparse§3.27
DISTINCT … STREAM with SORT, TAKE or SKIPparse§3.27
Window aggregation without SINCEparse§3.25
Scalar and window aggregation togetherparse§3.25
Two transformsparse§3.25
Cross-type filter without SINCEparse§3.26
= outside a label selectorparse§3.20
== NULL or != NULLparse§3.19
Ordering operator on a binary literalparse§3.20
Ordering operator on a fixed field that cannot be orderedparse or planning§3.20
Unknown log field nameparse§3.24
Effective range beyond the lookback limitplanning§3.26
Selected metric series spanning more than one typeexecution§3.25
RATE or DELTA on a gauge or histogramexecution§3.25
Percentile on a counter or gaugeexecution§3.25
Histogram series with no percentile functionexecution§3.25
Unbracketed metric query with SINCE resolving to several seriesexecution§3.25
Cross-type metric selector resolving to several seriesexecution§3.26
Aggregation producing a non-finite valueexecution§3.23, §3.25
A sort or aggregation needing more memory than the collector allows queriesexecution§3.16

"Parse" failures need no data. "Execution" failures depend on what the store holds, so the same query string may succeed on one system and fail on another.

3.B.4 Metric functions #

KeywordStageValid onProduces
RATEtransformcounterper-second change
DELTAtransformcounterabsolute change
P50 P95 P99transformhistogramone value per sample
AVG MIN MAX SUMscalar aggregationcounter, gaugeone value
AVG_OVER MIN_OVER MAX_OVER SUM_OVERwindow aggregationcounter, gaugeone value per window

Transforms feed aggregations; a query may have at most one of each (§3.25).

3.B.5 Operators #

== != > >= < <= STARTS_WITH ENDS_WITH CONTAINS HAS IN NOT_IN IS NULL IS NOT NULL, combined with AND and OR (§3.20).

CONTAINS is substring on a string; HAS is membership in an array.

There is no NOT and no =.

3.B.6 Literals #

KindFormSection
Identifier[A-Za-z_][A-Za-z0-9_.-]*§3.19
String"…" with \" \\ \n \r \t \uXXXX§3.19
Binaryx"0a1b…", even digit count§3.19
Integerdecimal or 0x…§3.19
Floatfinite, with a fraction or exponent§3.19
Booleantrue, false§3.19
NullNULL, in IS NULL only§3.19
Duration<n>s <n>m <n>h <n>d, non-zero§3.19
Time<duration> ago, <duration> hence, today, yesterday, YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS§3.19
GUID8-4-4-4-12, braced or not§3.19

3.B.7 Fields #

ModeFixed fieldsEverything else
EVENTSevent.time event.sequence event.cpu event.type event.boot.guid emitter.class emitter.token.guid emitter.true-token.guid emitter.process.guida flattened payload path, or null
LOGStimestamp origin is_error message boot_id job_ida parse error
METRICtimestamp boot_id name type value overflowa label, or null

3.B.8 Aliases #

emitter.class accepts userspace (0), kmes (1), kacs (2), lcs (3). These are the only aliased values in the language (§3.23).

3.B.9 Default ordering #

ModeWithout SORT
EVENTSevent.time descending
LOGStimestamp descending
METRICtimestamp ascending
COUNT BY, TOP N BY, GROUP … COUNTcount descending
GROUP … SUM, AVG, MIN, MAXgroup keys ascending in the order named
DISTINCTby the distinct value

All ties are broken to a total order (§3.21).

Appendix 3.C Prior Art

Peios / Peios technical reference / PSPU / Observability Interfaces

The three interfaces here are not novel, and each has a well-known counterpart whose shape informed it. What follows compares the contracts — this appendix is about wire shapes and the obligations they place on either side. The eventd TRMP §1.4 compares the systems.

3.C.1 Log ingestion #

The closest relative is journald's native socket: a Unix datagram socket, world-writable, accepting a self-describing record from any local process, with no acknowledgement and no notification of loss. The agreements are substantive — datagram rather than stream, silent drop under pressure, and a forwarder bridging programs that only know standard output. The identity model differs: this interface admits only the service-manager broker, which assigns the origin from the pipe it read rather than accepting a process's self-assertion.

The differences are three. The record here is MessagePack rather than a line-oriented key-value text format, because the collector already carries a MessagePack decoder for event payloads and a second parser would be a second thing to get wrong. Severity is a boolean rather than a syslog priority, because a forwarder can distinguish two file descriptors and inventing eight levels from two would be a guess presented as data (§3.7). And a batch is a first-class datagram shape rather than a stream of records, which is what lets a forwarder amortise the syscall without giving up the datagram's all-or-nothing property.

Classic syslog over /dev/log is the older relative, and the departure from it is the same one journald made: a record with named fields rather than a formatted line that every consumer re-parses with a regular expression.

3.C.2 Metric ingestion #

The shape is StatsD's: push, datagram, fire-and-forget, no series registration, sender-named series. KACS sender identity and per-name publication authorization are deliberate additions. It is the opposite of Prometheus's, where the collector pulls from endpoints it has been configured to know about.

The choice follows from the loss model rather than from taste (§3.9). A pulling collector must reach every producer on a schedule, which makes it responsible for their availability; pushing keeps a slow or dead producer invisible except for the gap it leaves.

What is taken from the Prometheus data model rather than from StatsD is the identity of a series: a name plus a set of labels, with each distinct label combination a distinct series, and the cardinality warning that comes with it (§3.10). The histogram is Prometheus's cumulative-bucket form, including the property that the top bucket is an overflow whose contents are counted but not located.

Two things are deliberately absent. There is no text exposition format, because nothing scrapes. And there is no summary type — a producer that has already computed its own quantiles cannot submit them, because quantiles do not aggregate and a stored one could not be combined with another (§3.25).

3.C.3 The query interface #

The unusual choice here is having a query language at all.

journald exposes a cursor and a set of field matchers, and computation belongs to the client. The Windows Event Log exposes XPath over an XML representation. Prometheus exposes PromQL, a genuine language, but only for metrics. This interface puts one language over all three data types, with a shared clause vocabulary and per-type modes (§3.18).

The reason is access control. Filtering, grouping and aggregation must happen on the side that knows what the caller may see, because a count computed by a client is a count of what the client was given and a count computed by the collector can be a count of what the client is entitled to (§3.28). A cursor interface pushes the computation across the trust boundary and takes the enforcement point with it.

The framing — a length-prefixed MessagePack request, a sequence of chunked result messages, one terminal message — is unremarkable and deliberately so. What it does not have is more interesting: no version field (§3.29), no error codes (§3.16), no multiplexing (§3.14), and no cursor. A query is one connection, and paging is SKIP and TAKE over a total order (§3.21) rather than an opaque token the collector must keep state for.

3.C.4 Where these interfaces sit #

ConcernWhere it is specified
Event emission and the ring-buffer transportPSPK
Event types and payload schemasthe emitting subsystem's own documentation
Tokens, SIDs and Security DescriptorsPCDS, and the Peios Kernel TRM
Forwarding a service's outputthe peinit TRM
Storage, indexing, retention, query planningthe collector's own design; for the mainline one, the eventd TRMP

4.1 Scope and Roles

Peios / Peios technical reference / PSPU / Service Control and Notification

This chapter defines the two interfaces a Peios service manager offers: the control channel, by which a program manages services, and the notification channel, by which a supervised service reports on itself.

Both are Unix-domain sockets between userspace parties, and both have a publicly implementable side. A monitoring tool, an orchestration agent, a shell utility, or a privileged action broker implements the client side of the control channel. Every supervised service that reports readiness, sends keepalives, or preserves file descriptors across a restart implements the producer side of the notification channel.

4.1.1 The roles #

The manager is the process that supervises services. It listens on both channels. On Peios this is peinit, running as PID 1, but nothing here depends on that beyond the manager being a single process holding both sockets.

A client connects to the control channel to issue commands and read answers. A client is any process; it holds no special relationship with the manager beyond the one its token establishes.

A service is a process the manager started, and speaks the notification channel about itself. A service does not connect to the control channel in that capacity — a program that does both is acting in two roles.

A job the manager started on a submitter's behalf (§7) speaks the notification channel exactly as a service does, and is observed and stopped from the control channel by the job commands of §4.14. The channel by which it is submitted is §7's.

Requirements are stated against the role, not the program.

4.1.2 What this chapter covers #

  • the two channels, their addressing, and how each is reached
  • message framing and encoding on both
  • how a client's identity is established, and how a command is authorised
  • the command set, the response shapes, and the error vocabulary
  • what a command does to a service in each of its states
  • how a service's notification is authenticated, and what a service may say
  • the file-descriptor store
  • the rules under which either channel may be extended
  • the conformance requirements for each role

4.1.3 What this chapter does not cover #

  • How the manager supervises anything. Dependency resolution, restart policy, timers, cgroups, the boot sequence and shutdown are the manager's own design. This chapter defines what a client can ask for and what it is told, not how the answer comes about.
  • How service definitions are expressed. On Peios they are registry keys, administered like any other registry data. That is the service manager's own design.
  • What a service state means. The vocabulary is fixed here (§4.B) because it appears on the wire; what causes a service to be in one of those states is not.
  • Kernel interfaces. Establishing a peer's identity and evaluating an access decision are kernel operations, specified in PSPK and in the kernel's own reference manual.
  • Submitting a job. The channel by which a process asks the manager to run a program under supervision, the identity such a job runs as, and the commands its submitter issues about it are §7. This chapter covers only what a control-channel client sees of such a job.

4.2 Terminology

Peios / Peios technical reference / PSPU / Service Control and Notification

Service. A named unit of execution the manager supervises. Service names are opaque to this chapter except for the character restriction in §4.8.

Job. One process execution. A service that has been restarted has had more than one job. A job submitted on the jobs channel (§7) has no service; it belongs to its submitter.

Operation. A requested state machine action on a service, with an identity and a lifecycle of its own. Lifecycle commands do not act directly; they create operations, and an operation is what a client observes and waits on.

Activation generation. A counter the manager increments each time a service begins starting. It distinguishes one incarnation of a service from the next.

Right. A named permission on a service or on the manager itself, represented as a bit in an access mask and evaluated against a Security Descriptor. §4.7.

Dependent-satisfying state. A service state in which the services that depend on the service may proceed. Which states these are is the manager's design; that a state is or is not one of them is observable through the state vocabulary.

Terminal state. For an operation, one of completed, failed, cancelled, merged or aborted. An operation in a terminal state does not change again.

Frame. One newline-terminated line on the control channel, carrying exactly one JSON object.

Datagram. One message on the notification channel, carrying zero or more KEY=VALUE lines and optionally file descriptors.

4.3 The Two Channels

Peios / Peios technical reference / PSPU / Service Control and Notification

The two channels differ in almost every respect, and the differences are deliberate.

ControlNotification
Socket typeSOCK_STREAMSOCK_DGRAM
Who connectsThe clientNobody; a service sends
AddressingA fixed pathA path given to each service
DirectionRequest and responseOne-way
FramingNewline-delimited JSONKEY=VALUE lines
IdentityThe peer's token, at connectThe sender's kernel-attested PID
AuthorisationAn access check per commandMembership: is the sender this service?
LossNone. A stream, or an errorPossible. A datagram may be dropped
OrderingGuaranteed within a connectionNot guaranteed

4.3.1 Why the notification channel is a datagram socket #

A service reporting on itself must not be able to block the manager, and must not block itself. A stream socket gives both parties a queue that fills, and a service writing into a full queue either blocks — hanging a service on the manager's scheduling — or gets an error it has to handle in the middle of doing something else.

A datagram socket has neither problem. A send either goes or is dropped, and the manager can drain at whatever rate it manages. The cost is that a notification can be lost, which is why nothing in §4.19 is a transaction: every field is either idempotent or a statement of current condition, and a service that needs a lost keepalive to have arrived sends another one.

4.3.2 Why the control channel is a stream socket #

A command has an answer, and a client waiting for one needs to know it did not arrive rather than assuming. It also needs framing: a request can be large, and a response certainly can.

4.3.3 Reaching either socket #

Both sockets are protected by the Security Descriptor on the socket's own inode, and a party that may not reach the socket is refused when it connects or sends, before any content is exchanged.

The manager MUST NOT rely on POSIX mode bits for this. On a Peios system access to a filesystem object is routed through its Security Descriptor, mode bits are not consulted, and a chmod on either socket has no effect whatever.

The manager MUST ensure that each socket, and each directory containing one, carries a Security Descriptor that admits the parties intended to use it. A socket created where nothing inheritable applies acquires no descriptor, and an object with no descriptor is denied to every caller — so a manager that leaves this to chance produces a socket nobody can reach, including principals its own default policy grants access to.

Note

The failure is quiet in both directions and neither direction announces itself. A socket in a permissive place is reachable by anything, and a socket in a bare one is reachable by nothing, and in both cases the manager binds successfully, reports itself ready, and serves no one it meant to.

4.4 The Control Channel

Peios / Peios technical reference / PSPU / Service Control and Notification

The manager MUST listen on a Unix SOCK_STREAM socket at a well-known path. On Peios that path is:

/run/services/peinit/control.sock

The socket MUST exist for as long as the manager is serving, and the manager MUST unlink it when it stops.

The manager MUST create the listening socket and every accepted connection with close-on-exec set, so that no connection descriptor is inherited by a process the manager starts.

4.4.1 A connection #

A client connects, issues one or more commands, and closes. The manager MUST NOT require a client to issue any command before another, and MUST NOT hold state across connections: a connection carries an identity (§4.6) and nothing else.

Requests on one connection MUST be answered in the order they were received. The manager MAY read no further frames from a connection while a response on it is outstanding.

4.4.2 Limits #

The manager MUST enforce three limits, and MUST make their values discoverable to an administrator through the same configuration surface that sets them. The values a Peios service manager uses by default are in §4.A.

Concurrent connections. A connection accepted while the manager is already at its limit MUST be closed at the socket level, without a response. There is no error code for this condition: the manager has declined to enter the protocol at all, and a client MUST treat an immediate close with no response as a refusal rather than as a protocol error.

The limit MUST be applied to each caller, counted by the user SID of the peer's token, as well as to the connections in all: a connection accepted while its caller already holds its limit is closed the same way. The manager MAY exempt the machine's own principal (SYSTEM) from the per-caller limit. A channel that admits every authenticated principal and bounds only the total lets any one of them fill it and shut everyone else out, administrators included.

Request size. A request frame whose content exceeds the limit MUST be answered with REQUEST_TOO_LARGE and the connection MUST then be closed. The limit applies to the frame's content and MUST NOT count the terminating newline, so a request of exactly the limit plus its newline is within bounds.

Idle timeout. A connection with no request outstanding MAY be closed once it has been idle for the configured period. The manager MUST NOT treat a connection as idle while a request on it is outstanding — in particular a connection blocked on a wait=true operation (§4.13) is not idle, however long the operation runs, and MUST be held open until the operation resolves. Such a connection is bounded by the operation's own timeout, not by the idle timeout.

A connection closed for idleness MUST be closed without a response.

4.5 Framing and Encoding

Peios / Peios technical reference / PSPU / Service Control and Notification

4.5.1 Frames #

Every message in both directions is one frame: a single JSON object, serialised compactly, followed by one 0x0A byte. This applies to requests and to responses alike, and the manager MUST terminate every response with a newline.

Framing is byte-oriented and is performed before any JSON is parsed. A 0x0A byte ends the frame wherever it appears, so a raw newline inside what a sender intended as a JSON string does not produce one frame with an embedded newline — it produces two malformed ones. (A raw 0x0A inside a JSON string is not valid JSON in any case; the \n escape sequence is unaffected and is the way to carry a newline in a value.)

The manager MUST NOT emit pretty-printed JSON, and MUST NOT emit more than one object per frame.

4.5.2 Encoding #

Frames are UTF-8. The manager MUST reject a frame that is not well-formed UTF-8 with MALFORMED_REQUEST.

4.5.3 What is malformed #

The manager MUST answer with MALFORMED_REQUEST when a frame:

  • is empty — a bare newline with no content;
  • is not well-formed UTF-8;
  • is not valid JSON;
  • is valid JSON but not an object. An array, a string, a number, true, false and null are all malformed requests.

4.5.4 Closing after an error #

The manager MUST distinguish two classes of failure, because they say different things about the connection.

A frame-level failure means the manager cannot trust the stream's framing any more: it does not know where the next frame begins. MALFORMED_REQUEST for an empty frame and REQUEST_TOO_LARGE are both frame-level. The manager MUST send the error response, discard any buffered input, and close the connection.

A command-level failure means the frame was well-formed and the command in it could not be carried out: unparseable JSON content, an unknown command, missing arguments, a denied access check, an unknown service. The manager MUST send the error response and MUST keep the connection open.

A client MUST NOT assume a connection survives an error response, and MUST be prepared for either.

4.5.5 Timestamps #

Every timestamp field the manager emits MUST be a UTC RFC 3339 string with exactly nine fractional-second digits and the literal offset marker Z:

"2026-06-01T12:34:56.123456789Z"

The manager MUST NOT emit a numeric offset in place of Z, and MUST NOT vary the number of fractional digits.

These are wall-clock instants, presented for a reader. The manager MUST NOT derive elapsed-time decisions — timeouts, retries, ordering — from wall-clock differences, and a client MUST NOT assume that two timestamps in the same response were taken from a clock that did not move between them.

4.6 Peer Identity

Peios / Peios technical reference / PSPU / Service Control and Notification

The manager MUST establish the identity of every client from the kernel. There is no credential exchange in this protocol, and a client MUST NOT be able to assert who it is.

4.6.1 Obtaining the identity #

On accepting a connection, the manager MUST obtain the peer's token through the kernel's peer-identity facility for connected local sockets (the Peios Kernel TRM §3.5). The token is captured by the kernel when the client connects; nothing in this protocol carries it, and a client has no way to supply one.

The token reflects the identity the client was acting under when it connected. A client that is impersonating another principal is therefore captured as the principal it is impersonating, not as its own service identity — which is the intended behaviour: access decisions reflect the identity a client is actually acting under.

4.6.2 When it is captured #

The manager MUST capture the identity once, when the connection is accepted, and MUST use that identity for every command on the connection.

A client MUST NOT expect a change of identity mid-connection to affect authorisation. A client that needs to act under a different identity MUST open a new connection.

Note

One identity per connection is what keeps a command sequence coherent. A job started under one identity cannot be steered by a later command issued under another on the same connection, every command on a connection is attributable to a single principal, and the manager never has to decide which of two identities an in-flight operation belongs to. The rule is a property of this protocol.

4.6.3 Failure #

If the manager cannot obtain the peer's identity, it MUST close the connection without a response. There is no error code, because the manager has no basis on which to decide whether this caller may be told anything at all.

A client MUST treat an immediate close with no response as a refusal. This is the same observable outcome as exceeding the connection limit (§4.4), and a client cannot distinguish the two — deliberately, since distinguishing them would tell an unauthenticated caller about the manager's state.

4.6.4 The manager's identity #

The kernel's facility is symmetric: a client that has connected MAY obtain the manager's identity through the same facility and verify it before sending a command — that the peer is the principal it expects the manager to run as, rather than something else that bound the path. A client MUST NOT treat the control socket's path as proof of the manager's identity; the path is a rendezvous, and the token is what the kernel attests.

4.6.5 The identity is not a UID #

The manager MUST NOT use the peer's UID or GID as an authorisation input. Identity on a Peios system is a token, and the token is what the kernel attests.

4.7 Authorising a Command

Peios / Peios technical reference / PSPU / Service Control and Notification

Every command is authorised against a Security Descriptor, using the peer's token. There is no command the manager performs without a check, and no principal exempt from one.

4.7.1 Rights #

Commands acting on a service are checked against that service's descriptor:

RightBitGrants
SERVICE_QUERY_STATUS0x0001Query the service's state and detail.
SERVICE_START0x0002Start the service.
SERVICE_STOP0x0004Stop the service.
SERVICE_INTERROGATE0x0008Reload the service.
SERVICE_ALL_ACCESS0x000FAll four.

Commands acting on a submitted job are checked against that job's own descriptor, with the rights and generic mapping §7.8 defines: JOB_QUERY (0x0001), JOB_STOP (0x0002), JOB_SIGNAL (0x0004).

Commands acting on the system are checked against the manager's own descriptor:

RightBitGrants
SYSTEM_SHUTDOWN0x0001Initiate a shutdown.
SYSTEM_RELOAD_CONFIG0x0002Re-read the configuration.
SYSTEM_QUERY_STATUS0x0004Ask how the machine booted (boot).

4.7.2 Generic mappings #

The manager MUST use these generic mappings when evaluating a descriptor, so that a descriptor written in generic terms means the same thing to every implementation.

For a service descriptor:

Generic rightMaps to
GENERIC_READSERVICE_QUERY_STATUS
GENERIC_WRITESERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE
GENERIC_EXECUTESERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE
GENERIC_ALLSERVICE_ALL_ACCESS

For the manager's descriptor:

Generic rightMaps to
GENERIC_READSYSTEM_QUERY_STATUS
GENERIC_WRITESYSTEM_RELOAD_CONFIG
GENERIC_EXECUTESYSTEM_SHUTDOWN
GENERIC_ALLSYSTEM_SHUTDOWN | SYSTEM_RELOAD_CONFIG | SYSTEM_QUERY_STATUS

GENERIC_READ maps to the one right on the manager's descriptor that changes nothing, as it maps to SERVICE_QUERY_STATUS on a service.

4.7.3 Per command #

CommandRight required
startSERVICE_START
stopSERVICE_STOP
restartSERVICE_START and SERVICE_STOP
reloadSERVICE_INTERROGATE
resetSERVICE_STOP
statusSERVICE_QUERY_STATUS
listEvaluated per service; see below
operation-statusSERVICE_QUERY_STATUS on the operation's target
job-statusJOB_QUERY on the job
job-listEvaluated per job; see below
job-stopJOB_STOP on the job
shutdownSYSTEM_SHUTDOWN
reload-configSYSTEM_RELOAD_CONFIG
bootSYSTEM_QUERY_STATUS

boot is checked like any other command, although what it reports is not a secret: a descriptor decides who may ask, and §4.15 asks that the default one let every authenticated principal.

reset requires SERVICE_STOP because clearing a terminal state is the tail of stopping something rather than the head of starting it.

4.7.4 The sequence #

  1. If the manager is shutting down, apply §4.15's restriction. The shutdown restriction is evaluated before the access check, so a caller who would have been denied is told the command is invalid for the current state. A client MUST NOT infer anything about its own rights from an INVALID_STATE received during shutdown.
  2. Resolve the target. A command naming no service the manager knows of MUST be answered UNKNOWN_SERVICE. The manager MUST NOT synthesise a descriptor for a service that does not exist.
  3. Evaluate the access check with the peer's token, the target's descriptor, the appropriate generic mapping, and the required right, naming the target in the check's audit context (PGSS §6.7).
  4. On denial, answer ACCESS_DENIED. The attempt is recorded by the access check itself, under the target descriptor's SACL, with the caller, the target and the rights requested and granted; the manager MUST NOT write an access-denied event of its own (PGSS §6.7). A descriptor the manager supplies by default MUST carry a SACL that audits every refusal, so that a denial is not silent unless a descriptor someone wrote says so.
  5. On grant, proceed.

A status, and each job view on this channel, also tells the caller which of these rights it holds on the target, as granted (§4.14). The manager finds them with this same check, asking for MAXIMUM_ALLOWED in step 3 instead of the command's right. That check is a question asked on the caller's behalf, not a command: a right it does not grant is left out of granted, and it is not a denial for step 4.

4.7.5 Filtering rather than denying #

list MUST return only the services the caller may query, and MUST omit the rest rather than denying the command. A caller with no query rights on anything receives an empty list and a successful response.

The response does not say whether services were omitted. What the filtering protects is each service's state, which is what SERVICE_QUERY_STATUS grants, and not whether the service exists: a command naming a service is answered UNKNOWN_SERVICE or by its access check according to whether it exists (step 2 above), and §4.10 does not hide existence either. A client MAY therefore learn from elsewhere which services are defined and show the ones list left out as ones whose state the caller may not see.

job-list MUST be filtered the same way, by JOB_QUERY on each job, and MUST NOT reveal that jobs were omitted.

4.7.6 Not revealing what a caller may not see #

A service's existence is not protected (above). Where a command names an operation the caller may not query, the manager MUST NOT let the answer distinguish "this does not exist" from "you may not see this".

For operation-status this means the authorisation check MUST be evaluated before the operation's existence is reported: a caller lacking SERVICE_QUERY_STATUS on an operation's target MUST receive ACCESS_DENIED whether or not the identifier names a real operation, and MUST NOT receive UNKNOWN_OPERATION for one that exists.

Where the caller's rights cannot be established because the target cannot be resolved, UNKNOWN_OPERATION is the correct answer.

For job-status and job-stop the rule is §7.10's, not this one: a job identifier is unguessable, so a caller that names one was told it, and the manager answers UNKNOWN_JOB for an identifier that names nothing and ACCESS_DENIED for one that names a job the caller may not touch.

4.8 Requests

Peios / Peios technical reference / PSPU / Service Control and Notification

A request is one JSON object.

{"command": "start", "service": "jellyfin", "wait": true}

4.8.1 Fields #

FieldTypeRequiredMeaning
commandstringalwaysThe command to run. §4.11, §4.14, §4.15
servicestringfor service commandsThe target service's name.
waitboolnoWhether to block until the operation resolves. §4.13
typestringfor shutdownpoweroff, reboot or halt.
operation_idstringfor operation-statusThe operation to report on.
job_idstringfor job-status, job-stopThe job.
submitterstringnoFor job-list: only jobs with this submitter SID.
identitystringnoFor job-list: only jobs whose identity is this SID.
logon_sessionintegernoFor job-list: only jobs whose identity is in this logon session.
statestringnoFor job-list: only jobs in this state (§7.B).

command MUST be present and MUST be a string naming a command the manager implements. A request whose command is absent, is not a string, or names no known command MUST be answered INVALID_COMMAND.

service MUST be present and a string for start, stop, restart, reload, reset and status. Its absence, or a non-string value, MUST be answered INVALID_ARGUMENTS.

wait MUST be a boolean when present. A non-boolean MUST be answered INVALID_ARGUMENTS. Its default is per command (§4.13).

type MUST be present and MUST be exactly one of the three values for shutdown. Anything else MUST be answered INVALID_ARGUMENTS.

operation_id MUST be present and a string for operation-status. A value that is not a well-formed identifier MUST be answered INVALID_ARGUMENTS.

job_id MUST be present and a string for job-status and job-stop, under the same rule. The four job-list filters are each optional; one that is present with a malformed value — a SID that does not parse, a state outside §7.B — MUST be answered INVALID_ARGUMENTS rather than matching nothing, and filters that are all present MUST all hold.

4.8.2 Fields that do not apply #

A field the command does not use MUST be ignored, not rejected. A service on a list, or a wait on a status, is accepted and has no effect.

This is what makes the request shape extensible: a client written against a later revision may send a field an earlier manager does not know, and the earlier manager ignores it. §4.21.

4.8.3 Service names #

A service name is 1 to 128 bytes drawn from [A-Za-z0-9._-]. The manager MUST NOT accept a name outside that set, and a client MUST NOT send one.

The restriction exists because service names are used as path components and as configuration key names by managers that store their definitions in a hierarchy. / and : are excluded specifically: the first because it is a separator wherever the name is used as a path component, and the second because it is conventionally reserved for a manager's own synthetic naming.

4.9 Responses

Peios / Peios technical reference / PSPU / Service Control and Notification

Every response carries a status field, which MUST be exactly "ok" or "error". What else it carries depends on which of four shapes it is.

4.9.1 The acknowledgement shape #

Returned by a lifecycle command that created, merged into, queued, cancelled, cleared or executed an operation.

{"status": "ok", "operation_id": "a1b2c3d4-…", "service": "jellyfin",
 "state": "active", "cause": "explicit_start", "warnings": []}
FieldTypeMeaning
operation_idstringThe operation to observe.
servicestringThe target.
statestringThe service's state when the response was formed. §4.B
causestring or nullWhy the service last transitioned. §4.B
warningsarray of stringsHuman-readable warnings. Often empty.
modestringFor a reload only. §4.13

warnings here is an array of strings. The status response uses the same field name for an array of objects (§4.14); a client MUST distinguish them by which command it sent, not by inspecting the array.

4.9.2 The status shape #

Returned by status, and also by a lifecycle command that had no effect — see §4.12. §4.14 gives it in full, including granted, the rights the caller holds on the service, which it carries either way.

4.9.3 The system shape #

Returned by shutdown:

{"status": "ok"}

Nothing else. A shutdown has no operation to observe and no service to report on. reload-config and boot have shapes of their own (§4.15).

4.9.4 The error shape #

{"status": "error", "code": "ACCESS_DENIED",
 "message": "caller lacks SERVICE_START on jellyfin"}

code MUST be one of the values in §4.10. message is human-readable and is not normative: a client MUST NOT parse it, match on it, or branch on its content. Two managers answering the same request with the same code MAY word the message differently.

4.9.5 Nullability #

A field that does not apply to the current state MUST be present and null rather than omitted, except where this chapter says otherwise. A client MUST accept null for any field this chapter marks nullable, and MUST NOT treat a null as an error.

The one exception is mode, which appears only on a reload response. The manager's structured events are not responses and do not follow this rule: their payloads follow PGSS §6.5, under which a value that does not apply is absent, never null.

4.10 Errors

Peios / Peios technical reference / PSPU / Service Control and Notification

The code field of an error response MUST be one of these values. The manager MUST NOT emit any other code, and a client MUST treat a code it does not recognise as an unrecoverable error for that request (§4.21).

CodeMeaningCloses?
MALFORMED_REQUESTThe frame is not a single well-formed JSON object. §4.5On an empty frame
REQUEST_TOO_LARGEThe request exceeds the configured maximum. §4.4Yes
INVALID_COMMANDcommand is absent, not a string, or names no known command.No
INVALID_ARGUMENTSA field the command requires is absent or malformed.No
UNKNOWN_SERVICEThe named service has no definition the manager can act on.No
UNKNOWN_OPERATIONThe operation identifier names nothing the manager holds — it never existed, or its retention has elapsed. §4.14No
UNKNOWN_JOBThe job identifier names nothing the manager holds — it never existed, or its retention has elapsed. §7.7No
ACCESS_DENIEDThe access check denied the requested right. §4.7No
INVALID_STATEThe command is not valid for the service's current state (§4.12), or the manager is shutting down (§4.15).No
OPERATION_TIMEOUTA wait=true request's operation did not reach a terminal state in time. §4.13No
INTERNAL_ERRORThe manager failed while executing the command.No

4.10.1 Distinctions a client can rely on #

UNKNOWN_SERVICE versus ACCESS_DENIED. A caller that may not query a service still receives UNKNOWN_SERVICE for a name that does not exist and ACCESS_DENIED for one that does but which it may not touch. This chapter does not attempt to hide the existence of services: the list filtering (§4.7) keeps a service's state from a caller that may not query it, not the fact that the service exists.

INVALID_STATE versus ACCESS_DENIED during shutdown. During shutdown the state restriction is evaluated first, so a caller who would have been denied receives INVALID_STATE instead. A client MUST NOT infer that it holds a right from receiving INVALID_STATE.

OPERATION_TIMEOUT does not cancel anything. It reports that the client's wait ended, not that the operation did. The operation continues and can still be observed with operation-status.

4.10.2 Codes are not extensible without a version #

The manager MUST NOT introduce a new code without the version negotiation in §4.21. A client written against this revision will not recognise one, and the only safe thing it can do with an unrecognised code is fail the request — so a new code silently converts a handled condition into an unhandled one.

4.11 Lifecycle Commands

Peios / Peios technical reference / PSPU / Service Control and Notification

Five commands move a service through its state machine. None of them acts directly: each creates, merges into, queues or cancels an operation, and the operation is what actually happens.

CommandEffectDefault wait
startStart the service.true
stopStop the service, escalating if it does not exit.true
restartStop then start, under one operation.true
reloadTell the service to re-read its configuration.false
resetClear a terminal state, returning the service to inactive.false

reload defaults to not waiting because a reload's outcome is often advisory, and a client usually wants the identifier rather than the block. reset is synchronous and completes before the response is sent, so waiting on it would mean nothing.

4.11.1 Operations #

The manager MUST return an operation identifier from any lifecycle command that created, merged into, queued, cancelled, cleared or executed an operation. The client uses it to poll (§4.14) or to correlate.

The manager MUST NOT invent an operation solely so that it has an identifier to return. Where a command had no effect, or the service was already in the state asked for, the manager MUST return the status shape instead of an acknowledgement (§4.12).

4.11.2 Merging #

Where an operation of the same type is already in flight for the same service, the manager MUST merge the new request into it and MUST return the existing operation's identifier.

A merged caller therefore receives an identifier that may be older than its own request, whose requested_at precedes the moment it sent the command. This is correct — that is when the work being waited on began — and a client MUST NOT treat an identifier older than its request as an error.

The manager MUST NOT tell the caller that a merge occurred. A merge is not a distinguishable outcome, and a client cannot do anything with the knowledge.

4.11.3 What completion means #

CommandThe operation completes when
startThe service reaches a dependent-satisfying state, or a state indicating its start-time conditions did not apply.
stopThe service is no longer running.
restartThe service reaches its normal successful start target after the restart.
reloadThe reload resolves, whatever its mode.
resetImmediately.

4.11.4 Timeouts #

Every queued or executing operation has a maximum lifetime, derived from the target service's own configured timeouts; a start the service manager is holding on an undecided dependency has none and MUST stay Pending until the dependency is decided.

The lifetime is measured from the operation's creation, including any time it spent queued. From the caller's point of view they have been waiting since they sent the command, and an operation that sat behind another for longer than its lifetime MUST fail rather than begin.

An operation whose lifetime expires while it is still queued MUST fail, and MUST fail its waiters. Expiry of the operation object MUST NOT by itself authorise the manager to act on the service — a stop operation that timed out while waiting its turn does not license signalling the service ahead of that turn.

4.12 Command Outcomes by State

Peios / Peios technical reference / PSPU / Service Control and Notification

A command sent to a service in an unexpected state MUST receive a defined answer. The manager MUST NOT silently do nothing.

inactivestartingactivereloadingstoppingcompletedbackofffailedabandonedskipped
startactmergealreadyalreadyqueueactdeferactinvalidact
stopnoopcancel + actactactmergeclearcancelnoopinvalidnoop
restartactqueueactactqueueactactactinvalidact
reloadinvalidinvalidactmergeinvalidinvalidinvalidinvalidinvalidinvalid
resetnoopinvalidinvalidinvalidinvalidinvalidinvalidclearclearclear
statusokokokokokokokokokok

4.12.1 The outcomes #

act — create an operation and execute it. The manager returns the acknowledgement shape.

merge — an operation of this type is in flight. The command merges into it (§4.11) and the caller receives that operation's identifier.

queue — the operation is created and left pending; it executes once the operation ahead of it completes. The caller receives the new operation's identifier.

defer — an automatic restart is already pending for this service. The manager MUST create a pending start operation, or merge into a deferred one that already exists, and MUST NOT execute it until the existing delay has elapsed. A start MUST NOT shorten a pending restart's delay.

already — the service is in the state the command would take it to and no operation of this type is in flight. The manager MUST return the status shape, not an error and not an acknowledgement.

noop — the command has no effect. The manager MUST return the status shape.

clear — the service returns to inactive. This is a synchronous outcome; the manager returns an acknowledgement.

cancel — abort or cancel the operation in flight, then proceed.

invalid — the command is not valid for this state. The manager MUST answer INVALID_STATE.

ok — status is answered from any state.

4.12.2 The backoff column #

A service in backoff is down with an automatic restart pending, and the four lifecycle commands mean different things there:

  • start defers, as above, and honours the remaining delay.
  • stop cancels both the pending restart and any deferred start, and the service becomes inactive.
  • restart cancels the automatic restart and performs a caller-initiated one.
  • reload and reset are invalid: there is no process to reload and no terminal state to clear.

4.12.3 The abandoned column #

Every lifecycle command except reset is invalid on an abandoned service. reset clears it. Nothing else is meaningful while processes the manager could not terminate are still present.

4.12.4 A service being withdrawn #

A manager MAY keep supervising a service whose definition has been removed while an instance of it is still running. In that condition the manager MUST answer start, restart and reload with UNKNOWN_SERVICE, MUST accept stop, and MUST report the condition in the status shape (§4.14). This holds whatever the service's state.

4.13 Wait Semantics

Peios / Peios technical reference / PSPU / Service Control and Notification

wait decides whether a lifecycle command's response is sent immediately or held until the operation resolves.

With wait: false, the manager MUST respond as soon as it has accepted the operation, with the acknowledgement shape and the service's state at that moment.

With wait: true, the manager MUST hold the connection open and respond when the operation reaches a terminal state, with the same shape and the service's state, cause and warnings observed at that time.

A connection blocked on a wait is not idle (§4.4). The manager MUST NOT close it for idleness however long the operation runs.

4.13.1 When a wait ends #

EndingResponse
The operation reaches a terminal stateThe acknowledgement shape.
The operation's lifetime expiresOPERATION_TIMEOUT.
The operation is no longer held by the managerUNKNOWN_OPERATION.

OPERATION_TIMEOUT ends the client's wait, not the operation. The operation continues, and the client MAY still observe it with operation-status using the identifier it never received — which it does not have. A client that needs to survive a timeout SHOULD issue the command with wait: false, keep the identifier, and poll.

4.13.2 Reload mode #

A response to a reload command MUST carry a mode field saying how the reload resolved:

ValueMeaning
confirmedThe service acknowledged the reload by signalling readiness. The reload demonstrably happened.
advisoryThe manager issued the reload and the service did not acknowledge it. The reload probably happened; nothing confirms it.
failedThe reload did not happen. An external reload command exited non-zero or timed out.

mode MUST be present on every response to a reload, including one sent with wait: false — in which case it MUST be advisory, since nothing has been observed yet.

A client MUST treat these three as an exhaustive set and MUST NOT expect a fourth. A manager MUST NOT introduce one without §4.21.

The distinction between confirmed and advisory is the whole value of the field: a service that implements the reload handshake (§4.19) can be known to have reloaded, and one that does not cannot. failed does not mean the service stopped — a failed reload leaves a running service running.

4.14 Query and Job Commands

Peios / Peios technical reference / PSPU / Service Control and Notification

Three commands read service state and change nothing. Three more observe and stop submitted jobs (§7), and are here because a client of this channel sees jobs alongside services.

4.14.1 status #

Returns everything the manager knows about one service.

{
    "status": "ok",
    "service": "jellyfin",
    "display_name": "Jellyfin",
    "description": "Media server for the living room.",
    "state": "active",
    "cause": "explicit_start",
    "status_text": "Listening on port 8096",
    "progress": null,
    "current_job": {
        "id": "a1b2c3d4-…",
        "type": "service_main",
        "pid": 1234,
        "started_at": "2026-06-01T12:34:56.123456789Z",
        "identity": "jellyfin-svc"
    },
    "current_operation": {
        "id": "e5f6g7h8-…",
        "type": "start",
        "source": "admin"
    },
    "health": "healthy",
    "uptime_seconds": 86400,
    "definition_removed": false,
    "warnings": [],
    "timers": [],
    "granted": ["query_status", "start", "stop"]
}
FieldTypeMeaning
display_namestring or nullThe definition's human-readable name.
descriptionstring or nullThe definition's human-readable description.
statestring§4.B.
causestring or nullWhy the service last transitioned. §4.B.
status_textstring or nullThe most recent status string the service sent (§4.19).
progressobject or nullThe most recent progress the service sent (§4.19), in the form §7.7 gives.
current_jobobject or nullThe current main job, or null if none.
current_operationobject or nullThe current operation, or null if none.
healthstring or nullhealthy, unhealthy, unknown, or null when the service has no health check configured.
uptime_secondsinteger or nullWhole seconds since the current job started. Null when nothing is running.
definition_removedboolTrue while the service's definition has been withdrawn and an instance is still draining (§4.12).
warningsarray of objectsConditions worth an operator's attention.
timersarray of objectsThe service's calendar timer triggers, as the manager has them armed. Empty when it has none.
grantedarray of stringsThe service rights the caller holds on this service. Never null.

current_job carries id, type (§4.B), pid, started_at and identity. pid and started_at are independently nullable. identity is the identity string the manager resolved for the execution, which is not necessarily what the resulting token contains.

current_operation carries id, type and source (§4.B).

The manager MUST clear status_text and progress to null at the start of every activation generation. A status string or a progress figure from a previous incarnation MUST NOT survive a restart and be reported as though it described the current process.

4.14.1.1 What the caller may do #

granted lists the service rights of §4.7 that the caller holds on the service, each by its name lowercased and without SERVICE_: query_status, start, stop, interrogate, in that order, and only those held. It describes the caller who asked, not the service; two callers asking about the same service may be told different things.

The manager MUST compute it with the access check it runs for a command (§4.7) — the caller's token, the service's descriptor, the service generic mapping — asking for MAXIMUM_ALLOWED rather than for one right, and MUST report only the service rights among what that check grants. A client can therefore offer only the commands that will be allowed: while the caller's token and the service's descriptor stay as they were, a command whose required rights (§4.7) all appear in granted passes its access check, and one needing a right that is absent fails it. Whether the command will then do anything is a separate question, which the service's state answers (§4.12), and during a shutdown §4.15's restriction comes first.

This check is a question, not a command. A right the caller lacks is simply absent from granted, and the manager MUST NOT record the check as a denial.

A status returned by a lifecycle command that had no effect (§4.12) carries granted for the caller of that command.

4.14.1.2 Status warnings #

warnings in the status shape is an array of objects, not strings:

{"path": "/sys/fs/cgroup/peinit/jellyfin/health",
 "type": "health",
 "detected_at": "2026-06-01T12:34:56.123456789Z"}
FieldTypeMeaning
pathstringWhat the warning is about.
typestringThe kind of warning. §4.B.
detected_atstringWhen the manager noticed. §4.5.

A client MUST accept a type it does not recognise and MUST NOT discard the warning, since a warning it cannot classify is still one an operator should see.

4.14.1.3 Status timers #

timers has one object for each calendar timer trigger of a service that is not disabled (for peinit, the peinit TRM §9), ordered by schedule:

{"schedule": "*-*-* 02:00:00",
 "scheduled_at": "2026-06-02T02:00:00.000000000Z",
 "fires_at": "2026-06-02T02:07:12.000000000Z",
 "last_fired_at": "2026-06-01T02:03:40.000000000Z",
 "not_armed": null}
FieldTypeMeaning
schedulestringThe calendar expression, as the trigger gives it.
scheduled_atstring or nullThe schedule's next occurrence. §4.5.
fires_atstring or nullWhen the timer will fire: that occurrence, delayed by the jitter drawn for it. Equal to scheduled_at without jitter. §4.5.
last_fired_atstring or nullWhen it last fired. Null when it has not fired since the manager started and no earlier firing is recorded. §4.5.
not_armedstring or nullWhy the manager has not armed it, when it has not: its schedule does not parse, or has no occurrence within the manager's search horizon. Null when armed.

An armed timer has scheduled_at and fires_at, and null not_armed. A timer not armed has only schedule and not_armed.

These are the manager's own figures, not a calculation a client could repeat. The jitter is drawn at random each time the timer is armed, and only the manager knows which schedules it refused, so a client MUST NOT compute a next firing from the definition and present it as when the service will run.

last_fired_at is the firing, not the run: it is when the manager acted on the timer (the peinit TRM §9.2), whatever came of that. For a persistent timer that has not fired since the manager started, it is the timestamp the manager recorded before (the peinit TRM §9.3).

4.14.2 list #

Returns every service the caller may query, with a compact summary.

{
    "status": "ok",
    "services": [
        {"service": "jellyfin", "display_name": "Jellyfin",
         "description": "Media server for the living room.",
         "state": "active", "cause": "explicit_start",
         "health": "healthy", "next_timer_at": null},
        {"service": "logrotate", "display_name": null,
         "description": null, "state": "inactive",
         "cause": "clean_exit", "health": null,
         "next_timer_at": "2026-06-02T00:03:51.000000000Z"}
    ]
}

Exactly seven fields per entry: the name, the definition's nullable display_name and description, the state, cause and health of the status shape, and next_timer_at, the soonest fires_at among the service's armed timers, or null when it has none. Services the caller may not query are omitted (§4.7).

A service whose definition has been withdrawn is listed, and the list entry does not say so. A client that needs to know MUST issue a status.

4.14.3 operation-status #

Returns one operation by identifier.

{
    "status": "ok",
    "operation": {
        "id": "e5f6g7h8-…",
        "type": "start",
        "service": "jellyfin",
        "source": "admin",
        "state": "completed",
        "result": "active",
        "merged_into": null,
        "error": null,
        "requested_at": "2026-06-01T12:34:56.123456789Z",
        "started_at": "2026-06-01T12:34:56.223456789Z",
        "completed_at": "2026-06-01T12:34:58.923456789Z"
    }
}
FieldMeaningPresent when
idThe operation's identifier.Always.
type§4.B.Always.
serviceThe target.Always.
sourceWhy the manager created it. §4.B.Always.
state§4.B.Always.
resultThe resulting service state.completed.
errorWhy it did not complete.failed, cancelled, aborted.
merged_intoThe surviving operation's identifier.merged.
requested_atWhen it was created.Always.
started_atWhen it began executing.Once running.
completed_atWhen it reached a terminal state.Once terminal.

Fields that do not apply to the current state MUST be null.

error carries a reason for all three non-success terminal states, not only for failed. A client MUST NOT read a non-null error as meaning the operation failed — it MUST read state for that. Cancellation and abortion have reasons worth reporting, and a separate field for each would give a client three places to look for one fact.

4.14.4 job-status #

Returns one submitted job by identifier, as the job view of §7.7.

{"command": "job-status", "job_id": "0190a3b2-…"}
{"status": "ok", "job": { … }}

The view is §7.7's, with one field added on this channel: what a submitter sees of its job on the jobs channel is what a client sees of it here, and the client is also told what it may do to the job.

4.14.4.1 What the caller may do to a job #

On this channel every job view — in the response to job-status, job-list and job-stop — carries one field beyond §7.7's:

FieldTypeMeaning
grantedarray of stringsThe job rights the caller holds on this job. Never null.
{"id": "0190a3b2-…", "type": "submitted", "state": "running", …,
 "granted": ["query", "stop"]}

It lists the job rights of §7.8 the caller holds, each by its name lowercased and without JOB_: query, stop, signal, in that order, and only those held. The manager MUST compute it as it computes a status's granted, with the access check it runs for a job command, against the job's own descriptor and the job generic mapping (§7.8), and the same rules follow: it is a question rather than a command, a right the caller lacks is absent rather than denied, and a command whose required right is listed passes its access check.

For a job-stop that waits, the manager MAY compute granted when it accepts the command rather than when it answers. A job's descriptor is fixed at submission (§7.8), so the two could differ only if the caller's token had changed in between.

The jobs channel's view does not carry granted. A submitter is answered there under §7.8's own rules.

4.14.5 job-list #

Returns every submitted job the caller may query, as job views.

{"command": "job-list", "identity": "S-1-5-21-…"}
{"status": "ok", "jobs": [ { … }, { … } ]}

Each entry is the full job view (§7.7), with granted (above). Jobs the caller may not query are omitted (§4.7). The four filters of §4.8 — submitter, identity, logon_session, state — narrow the result before it is filtered by right, and the manager MUST NOT let the response reveal whether the filters or the rights removed an entry.

Terminal jobs still within their retention period are listed; a client that wants only live ones filters by state.

Note

logon_session is what an authority uses to find everything running under a logon it is ending: it lists the jobs in that session and stops each it holds JOB_STOP on. A user-facing view of "my jobs" filters by identity; a broker's view of "the jobs I started" filters by submitter.

4.14.6 job-stop #

Stops one submitted job, with the semantics of the jobs channel's stop (§7.8): the termination signal, the job's stop_timeout, then the kill.

{"command": "job-stop", "job_id": "0190a3b2-…", "wait": true}

wait defaults to true, and the response is the job view, with granted, when the job is terminal; with wait: false it is the view as soon as the stop has been initiated. A job-stop on a terminal job returns the view unchanged, with "status": "ok".

This is the one command on this channel that acts on a job, and it creates no operation: a job has no state machine to contend for (§7.6). There is no job-signal here; signalling is the submitter's business, on its own channel.

4.14.7 Retention #

The manager MUST hold an operation record for at least a grace period after it reaches a terminal state, so that a client polling for the result can retrieve it. The value a Peios service manager uses is in §4.A.

An identifier that never existed, and one whose record has been dropped after its grace period, MUST both be answered UNKNOWN_OPERATION. A client MUST NOT distinguish them, and MUST treat UNKNOWN_OPERATION after a successful acknowledgement as meaning the result is no longer available rather than that the operation never ran.

Job records are retained under §7.7's rule, and UNKNOWN_JOB carries the same two meanings.

4.15 System Commands

Peios / Peios technical reference / PSPU / Service Control and Notification

Three commands are about the manager rather than a service. Two act on it; the third, boot, asks how the machine booted.

4.15.1 shutdown #

{"command": "shutdown", "type": "reboot"}

type MUST be one of:

ValueMeaning
poweroffStop everything and remove power.
rebootStop everything and restart the machine.
haltStop everything and halt, leaving the machine powered.

The response is {"status": "ok"} and nothing else. There is no operation to observe: a shutdown is a mode the manager enters, not an action on a service, and by the time it has finished there is nobody left to tell.

A client MUST NOT expect the connection to survive. The manager MAY close it at any point after the response.

4.15.2 reload-config #

Re-reads the configuration and rebuilds whatever the manager derives from it.

{
    "status": "ok",
    "summary": {
        "added": ["jellyfin"],
        "updated": ["sshd"],
        "restored": [],
        "marked_removed": ["old-migration"],
        "discarded": ["obsolete-timer"]
    },
    "warnings": []
}
FieldTypeMeaning
addedarray of stringsServices that did not exist before.
updatedarray of stringsServices whose definition changed.
restoredarray of stringsServices whose withdrawal was reversed.
marked_removedarray of stringsServices whose definition is gone but which are still running.
discardedarray of stringsServices removed outright.
warningsarray of stringsHuman-readable warnings about the new configuration.

Every member of summary MUST be present, even when empty. A client MUST accept a member of summary it does not recognise, and MUST ignore it (§4.21).

4.15.2.1 It is atomic #

The manager MUST validate the new configuration in full before adopting any of it, and MUST adopt it only if validation succeeds. If validation fails, the manager MUST leave the previous configuration in force and MUST answer INVALID_STATE, reporting what was wrong.

A partially applied configuration is worse than the one already running: the running one at least booted.

4.15.2.2 It does not live-update #

The manager MUST NOT reconfigure a running service. A changed definition takes effect the next time that service starts.

4.15.3 boot #

Reports how the current boot went: which mode the machine booted in and why, how close it is to recovery, and whether this boot has counted as a successful one yet. It takes no arguments and changes nothing.

{"command": "boot"}

A boot mode is how much of the configuration a boot starts. In full mode the manager starts everything configured to start at boot. In safe mode it starts a reduced set: the services the boot cannot do without, which this chapter calls critical services, and those marked as wanted in Safe mode. In recovery mode it starts nothing and offers a maintenance shell instead. Which services are critical, and what each mode starts, is the manager's design.

A boot is confirmed once it has shown it works: every critical service has held a dependent-satisfying state (§4.2) for a grace period without a break. The manager keeps a boot attempt count, the number of boots in a row that started and were never confirmed, and enters recovery when it reaches a threshold rather than boot into the same failure again. Confirming a boot puts the count back to 0.

{
    "status": "ok",
    "boot": {
        "mode": "safe",
        "reason": "safe_mode_downgrade",
        "downgrade": ["critical service in dependency cycle storage -> network"],
        "attempts": 1,
        "max_attempts": 3,
        "confirmed": false,
        "grace_seconds": 30,
        "waiting_on": ["storage"],
        "confirms_at": null,
        "confirm_error": null
    }
}
FieldTypeMeaning
modestringThe mode the machine booted in, after any downgrade. §4.B
reasonstringWhy it is in that mode. §4.B
downgradearray of stringsWhat forced a downgrade, one human-readable finding each. Empty unless reason is safe_mode_downgrade.
attemptsintegerThe boot attempt count as the manager checked it against the threshold at the start of this boot.
max_attemptsintegerThe threshold. 0 means the check is off and the count never sends the machine to recovery.
confirmedboolThis boot has been confirmed, and the count has been put back to 0.
grace_secondsintegerThe grace period, in seconds.
waiting_onarray of stringsThe critical services not yet holding a dependent-satisfying state. Empty once the grace period has ended.
confirms_atstring or nullWhen the boot will be confirmed if every critical service keeps holding, in RFC 3339 UTC. Null while waiting_on is not empty, and once the grace period has ended.
confirm_errorstring or nullWhy the count could not be put back to 0 when the grace period ended. Human-readable.

Every member of boot MUST be present. A client MUST accept a member of boot it does not recognise, and MUST ignore it (§4.21).

attempts counts the boots before this one; this boot is not among them. A machine reaches recovery when a boot begins with attempts at or above max_attempts, so max_attempts - attempts is how many more unconfirmed boots the machine has before recovery.

confirmed MUST be true only once the count has actually been put back to 0. If the grace period ended but the manager could not write the count, confirmed is false and confirm_error says why: the next boot will count this one as a failed attempt, whatever its services did, and a client MUST NOT report the boot as confirmed.

A confirmed boot stays confirmed. A critical service that fails after confirmation is a failure of that service, not of the boot, and does not make confirmed false again.

A manager in recovery mode does not serve the control channel, so a client SHOULD NOT expect to read recovery from boot; the value exists so that a manager which does serve it there has a word for it.

boot is checked against the manager's descriptor for SYSTEM_QUERY_STATUS (§4.7). How a machine booted is not a secret, and a manager's default descriptor SHOULD grant this right to every authenticated principal.

4.15.4 During shutdown #

Once the manager is shutting down, it MUST reject every command except status, list, operation-status, boot, job-status, job-list and job-stop with INVALID_STATE.

The first six are permitted because they change nothing and because a client watching a shutdown proceed has a legitimate reason to keep looking; job-stop because a job the shutdown will reach eventually may be one a client has reason to end now. Everything else — including a second shutdown — is refused: the manager has committed to a course of action and a command that would alter it arrives too late to be honoured consistently.

As §4.7 says, this restriction is evaluated before the access check, so a caller who would have been denied receives INVALID_STATE instead.

4.16 The Notification Channel

Peios / Peios technical reference / PSPU / Service Control and Notification

A service reports on itself over a Unix SOCK_DGRAM socket the manager binds and holds for the lifetime of the system.

4.16.1 Addressing #

The manager MUST make the socket's path available to each service it starts, in the NOTIFY_SOCKET environment variable, set in the service process's environment before exec.

The path is not part of this contract, and a service MUST NOT hardcode one. The manager MAY bind one socket for all services or one per service; a service cannot tell and MUST NOT depend on either.

The manager MUST set NOTIFY_SOCKET unconditionally, for every service it starts, whatever readiness protocol that service uses. A service uses this channel for keepalives, status, timeout extension and the descriptor store as well as for readiness, and a manager that set the variable only for services expected to signal readiness would make the rest unreachable.

The manager MUST NOT allow NOTIFY_SOCKET to be overridden by any configurable environment layer. A service that could override it would silently disable its own supervision.

4.16.2 Direction and delivery #

The channel is one-way. The manager does not reply, and a service MUST NOT wait for one.

Delivery is not guaranteed. A datagram MAY be dropped, by the kernel under load or by the manager. Every field in §4.19 is therefore either idempotent or a statement of a current condition, and a service that needs an effect to have taken hold sends the field again rather than waiting for an acknowledgement that does not exist.

The manager MUST NOT let this channel exert backpressure on a service. A service MUST NOT be able to block by sending, and the manager MUST NOT require a service to slow down.

4.16.3 Bounds #

The manager MUST accept a datagram of at least the size in §4.A, and MUST accept at least the number of file descriptors in §4.A in one datagram's control message.

The manager MUST detect a datagram that exceeded either bound and MUST reject the whole datagram (§4.17). It MUST NOT process a truncated datagram: a truncation can leave a tail that parses as a complete, valid line, which would apply a field the sender did not send.

A service MUST NOT send a datagram exceeding either bound.

4.17 Datagram Framing

Peios / Peios technical reference / PSPU / Service Control and Notification

A datagram carries zero or more lines, separated by 0x0A. Each line is KEY=VALUE.

READY=1
STATUS=Listening on port 8096

A trailing newline on the last line is permitted and is not a line of its own. A trailing 0x0D on any line MUST be stripped before the line is interpreted, so a sender that emits CRLF is understood.

The datagram MUST be well-formed UTF-8.

4.17.1 Applying a datagram #

The manager MUST parse every line before applying any of them, and MUST apply every line of a datagram it accepts, in order.

If any line is malformed, the manager MUST reject the entire datagram and apply nothing from it. Any file descriptors it carried MUST be closed.

Partial application is the failure this rule exists to prevent. A datagram saying RELOADING=1 and something unintelligible has an ambiguous meaning, and applying the half that parsed picks one reading of it silently.

4.17.2 What is malformed #

A line is malformed when it is non-empty and:

  • it contains no =; or
  • its key is empty.

An empty line is not malformed. It is skipped.

A datagram is malformed when it is not well-formed UTF-8, or when it exceeded a bound in §4.16.

4.17.3 Three ways a line can fail to take effect #

These are distinct and a service author needs the distinction:

SituationEffect on the datagramEffect on the line
A malformed lineRejected entirely—
An unrecognised keyApplied normallyIgnored
A recognised key with an unexpected valueApplied normallyIgnored

The second is what makes the field set extensible (§4.21): a service built against a later revision may send a field an older manager does not know, and the older manager applies the rest.

The third is the one that surprises. READY=0 is not a malformed line and does not reject the datagram; READY expects the value 1 and anything else is silently ignored. A service MUST NOT send a recognised key with a value the field does not define, and MUST NOT expect to be told when it does. §4.19 gives each field's accepted values.

4.17.4 Rejection is recorded, not answered #

The manager MUST record a rejected datagram, with at least the sender's identity and the reason, and MUST attribute it to a service where the sender could be identified.

It MUST NOT reply. There is nothing to reply on.

4.18 Sender Authentication

Peios / Peios technical reference / PSPU / Service Control and Notification

A datagram on this channel claims to be a supervised process — a service, or a submitted job (§7) — talking about itself. The manager MUST establish that it is.

4.18.1 The requirements #

The manager MUST enable SO_PASSCRED on the socket, and MUST reject any datagram arriving without a kernel-attested credentials control message.

It MUST then establish all of the following, and MUST drop the datagram if any fails:

  1. The sender is a service's current main job, or a submitted job. The manager MUST match the attested PID against the main jobs and the submitted jobs it is supervising. A hook process, a health check, or a child a service or a job forked MUST NOT be able to notify on its behalf.
  2. That job has exec'd and is running. A job still in setup has not become the service yet.
  3. That job has a kernel handle on the process — a pidfd, or an equivalent that refers to one specific process rather than to a number.
  4. The handle still refers to the attested PID. The manager MUST verify the PID against the handle rather than trusting the PID alone.
  5. For a service's job, its activation generation is the service's current one. A submitted job has no generation and no replacement; the step does not apply to it.

4.18.2 Why steps 3 and 4 exist #

A PID identifies a process only until that process exits. Between a service writing a datagram and the manager reading it, the service can die and its PID be recycled onto something else — and PID matching alone would then attribute the unrelated process's message to the service, or attribute the service's message to whatever now holds the number.

A handle obtained atomically at fork does not have that property. Verifying the attested PID against the handle is what turns a probable match into a certain one.

4.18.3 Why step 5 exists #

A datagram sent by an incarnation of a service that has since been restarted MUST NOT be applied to its replacement. Without the generation check, a READY=1 written by a process moments before it crashed could mark the process that replaced it ready — declaring a service healthy on the strength of a message from the one that just failed.

Readiness is per activation generation, and so is everything else on this channel.

4.18.4 What the manager MUST NOT use #

The manager MUST NOT use the sender's UID or GID as an authorisation input, and MUST NOT accept any identity a service asserts in the datagram's content.

Identity on this channel is which supervised process this is, and only the kernel can attest that. A service does not have a name here that it gets to state, and a job does not have an identifier.

4.18.5 What a job may send #

A submitted job may send every field in §4.19. Those that address a service transition it is not in — RELOADING, EXTEND_TIMEOUT_USEC outside a readiness wait, the descriptor-store fields, which a job has no store for — MUST be applied as §4.19 says for the case where they do not apply: ignored, or closed and recorded. READY=1 from a readiness: none job is ignored. A job's STATUS and PROGRESS are retained and exposed in its job view (§7.7).

4.19 Notification Fields

Peios / Peios technical reference / PSPU / Service Control and Notification

Every field a service may send. A manager MUST implement all of them. A service MUST NOT send a recognised key with a value the field does not define (§4.17).

4.19.1 Lifecycle #

FieldValueMeaning
READY1Startup is complete and the service is serving.
RELOADING1Configuration reload has begun.
STOPPING1Graceful shutdown has begun.
LEVELfree textA currently true readiness condition. Repeated fields publish the complete set; an empty-only set retracts it.

READY=1 is what a service using notification readiness sends when it is genuinely able to serve, not when its process exists. Anything depending on the service starts on the strength of it, so a service that signals early declares its dependents' assumptions true before they are.

RELOADING=1 opens a reload. The manager waits a bounded period after issuing a reload for this field; a service that sends it MUST follow with READY=1 when the reload is complete, and the pair is what lets the manager report the reload confirmed rather than advisory (§4.13). A service that never sends either still reloads — it just cannot be observed to have done so.

STOPPING=1 tells the manager the service is already shutting down. A manager that receives it MUST NOT send a further termination signal to that service. It MUST NOT extend or reset the stop timeout: the service still has to exit within it, and a service needing longer sends EXTEND_TIMEOUT_USEC.

LEVEL names a condition the service currently satisfies, in the service's own vocabulary. A service MAY repeat this field within one datagram to publish several conditions. For example, a routed netd sends LEVEL=link, LEVEL=addressed and LEVEL=routed as three lines in one datagram.

When a datagram contains at least one LEVEL field, the manager MUST atomically replace the sender's retained set with the distinct nonempty values from all its LEVEL fields. Field order and duplicates MUST NOT change the resulting set. An empty value adds no member; a datagram with only empty LEVEL values MUST clear the set. A datagram without LEVEL MUST leave the retained set unchanged. The entire replacement MUST be visible before releasing dependents, including when the datagram also contains READY=1.

The manager MUST discard the set whenever the service leaves a state that satisfies dependents: these are claims made by a running process.

A level-conditioned dependency (Requires = ["netd:routed"]) MUST NOT be satisfied unless the target is in a dependent-satisfying state and the retained set contains the named level. Names are opaque, case-sensitive and per-publisher; commas have no special meaning. The manager MUST NOT assume an ordering or implication between them. A publisher that knows several conditions hold MUST publish each one that it promises to expose to dependents.

The value SHOULD be a single token of at most 64 printable ASCII characters. A value outside that shape is not an error, but a dependency condition cannot name it, so nothing can ever wait on it.

4.19.2 Health #

FieldValueMeaning
WATCHDOG1A keepalive.
WATCHDOG_USECunsigned integerChange the expected keepalive interval, in microseconds.
EXTEND_TIMEOUT_USECunsigned integerExtend the current transition's deadline, in microseconds.

WATCHDOG_USEC with a value above zero sets the interval and MUST re-arm the timer from the moment the message arrives, rather than letting the new interval apply only from the next keepalive. A value of zero MUST disable the watchdog.

The value MUST NOT persist across a restart. A restarted service gets the interval its definition specifies.

EXTEND_TIMEOUT_USEC sets the current transition's deadline to expire that many microseconds from the message's arrival. It replaces the deadline rather than adding to it, and MAY be sent repeatedly.

Because it replaces, a value smaller than the time remaining shortens the deadline, and zero expires it immediately. A service MUST NOT send a value expecting it to be treated as a floor.

The manager MUST cap the extended deadline at four times the base timeout of the phase being extended, and MUST clamp rather than reject a value beyond the cap. During a system shutdown the manager MUST additionally cap it at the time remaining in the shutdown, and where both apply the stricter MUST win.

A message arriving while the service is not in a transition MUST be ignored. There is no deadline to extend.

4.19.3 Reporting #

FieldValueMeaning
STATUSfree textA human-readable statement of what the service is doing.
PROGRESSsee belowHow far the service has got.
PROGRESS_UNITbytes, items or percentWhat PROGRESS counts.
ERRNOfree textAn errno-style error number.
EXIT_STATUSfree textAn exit status, informationally.

All five MUST be authenticated like any other field and MUST be recorded by the manager as structured events (PGSS §6), which, like every event, are not written while the emission policy has their type switched off (PGSS §6.9). They MUST NOT be forwarded to a log sink as though they were the service's output — they are the service speaking to the manager.

STATUS, PROGRESS and PROGRESS_UNIT MUST additionally be retained and exposed — as status_text and progress in the status shape (§4.14), and in a submitted job's view (§7.7). ERRNO and EXIT_STATUS MUST NOT be retained.

4.19.3.1 Progress #

PROGRESS takes one of three forms, each of two unsigned decimal integers at most, separated by /:

FormMeaningA viewer shows
NCounting, with no end.A rising count.
N/Counting towards an end that exists but is not yet known.A rising count, and expects a bound to arrive.
N/TN of T.A bar.

T MUST be at least 1, and N MUST NOT exceed T when T is given. A value outside these forms, or violating either rule, is an unexpected value under §4.17: the line is ignored and the datagram is otherwise applied. A manager MUST NOT clamp or repair a progress value.

The manager retains the most recent accepted value and exposes it as a progress object: current is N; total is T or null; bounded is true for the second and third forms and false for the first; unit is the most recent accepted PROGRESS_UNIT or null. A PROGRESS_UNIT outside its three values is an unexpected value and is ignored. Each field is replaced only by a datagram that carries it: a STATUS alone leaves progress as it was.

T MAY change from one datagram to the next — a job that rescans has learned something — and N MAY fall. A viewer renders the latest value; it MUST NOT infer motion from the difference between two.

A service that sends progress with no end in sight MUST send the first form and not a guessed T. A service that has not started counting sends nothing: absence means no progress concept, and a viewer shows an indeterminate indicator.

The manager MUST bound how often a change of progress becomes an event on its event stream, so that a sender updating a thousand times a second cannot produce a thousand events; the retained value is the latest regardless. The bound a Peios service manager uses is §4.A.

A service MUST NOT include a newline or carriage return in a STATUS value: it would frame as two lines, the second of which is almost certainly malformed.

4.19.4 The descriptor store #

FieldValueMeaning
FDSTORE1Store the descriptors attached to this datagram.
FDNAMEfree textThe name to store or remove them under.
FDSTOREREMOVE1Remove the descriptors stored under FDNAME.
FDPOLL0Do not monitor the stored descriptors for error conditions.

§4.20.

4.19.5 Fields that are not supported #

FieldWhy
MAINPIDA manager supervises the process it forked, through a kernel handle obtained at fork. There is no mechanism for redirecting supervision to another process, and there is deliberately none: a service that could nominate its own supervision target could nominate anything.
BUSERRORPeios has no D-Bus.

Neither is rejected distinctly. Both are simply unrecognised keys and are ignored like any other (§4.17). A service MUST NOT rely on being told that it sent one.

4.20 The Descriptor Store

Peios / Peios technical reference / PSPU / Service Control and Notification

A service may hand file descriptors to the manager and get them back after a restart it did not choose. This is what lets a stateful daemon — one holding a listening socket, say — restart without dropping what it already had.

The manager MUST support a per-service maximum, which MAY be zero. Zero disables the store for that service, and a service MUST NOT assume a store exists.

4.20.1 Storing #

On an authenticated datagram carrying FDSTORE=1 with descriptors attached, the manager MUST:

  1. If the store is disabled for this service, close the descriptors and record the rejection.
  2. If the store already holds its maximum, close the descriptors and record the rejection. It MUST NOT evict an existing entry — a full store is full, and silently discarding something the service is relying on to survive a restart would be worse than refusing the new one.
  3. Store them under the value of FDNAME if present and non-empty, and under the name stored otherwise.
  4. Note FDPOLL=0 if present.

A datagram MAY carry several descriptors. Each becomes its own entry under the one name, and each is independently subject to the maximum — so a datagram carrying more than will fit has some stored and the rest closed.

Several entries MAY share a name.

FDPOLL=0 asks the manager not to monitor the descriptors for error conditions. A manager MAY monitor stored descriptors and remove ones that have become invalid; a manager that does not MUST still accept the field.

4.20.2 Removing #

FDSTOREREMOVE=1 with FDNAME MUST remove every entry of that name and close its descriptors. A name matching nothing is a no-op and MUST NOT be an error.

FDSTOREREMOVE=1 without FDNAME MUST be treated as a malformed line, rejecting the whole datagram (§4.17). A remove with no name has no defined meaning, and the alternative readings — remove everything, remove the default name, do nothing — are far enough apart that guessing between them silently is worse than refusing.

4.20.3 Returning them #

When the service starts again, the manager MUST pass the stored descriptors to the new process:

  1. Placed consecutively, starting at descriptor 3, with close-on-exec cleared.
  2. LISTEN_FDS set to the number of descriptors passed.
  3. LISTEN_FDNAMES set to the names, colon-separated, in the same order as the descriptor numbers.
  4. LISTEN_PID set to the new process's own PID.
  5. The store cleared.

LISTEN_PID is what lets a service verify that the variables are addressed to it rather than inherited from an ancestor. A conforming client checks it against its own PID before trusting LISTEN_FDS, and treats a mismatch as meaning no descriptors were passed — so a manager that omits it hands descriptors to a service that will not take them.

All four variables MUST be absent when no descriptors are passed, and the manager MUST NOT allow any of them to be set by a configurable environment layer. A LISTEN_FDS reaching a service that was passed nothing points its descriptor-adopting code at whatever happens to be at descriptor 3.

Descriptors are returned to the service's main process only. A hook or a probe MUST NOT receive them.

The store MUST be cleared once the descriptors have been passed. The manager MUST NOT clear it when a start attempt fails before that point — the descriptors are still the service's, and the next attempt should get them.

A returned listening socket conveys, to every client that connects, the identity of the process that called listen() on it — the previous instance of the service. A service that receives listening sockets from the store SHOULD restamp them with its own identity before accepting (the Peios Kernel TRM §3.5), so that clients verifying the peer see the instance actually serving them.

4.20.4 When the store is emptied #

The manager MUST clear the store, closing its descriptors, when:

  • the service is stopped deliberately — by a client, or as part of a system shutdown; or
  • the service's definition is withdrawn and its entry is finally discarded.

The manager MUST NOT clear it on a restart the service did not ask for — a crash, or a restart policy acting on one. That case is the entire purpose of the mechanism: the descriptors survive exactly the restart the service could not prepare for.

Note

LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID are the convention established by systemd's sd_listen_fds, and are specified here in the same form deliberately. Software already written to adopt descriptors that way works against a Peios service manager without modification.

4.21 Extension

Peios / Peios technical reference / PSPU / Service Control and Notification

Neither channel carries a version number. Both are extended by the rules below, which are what allow a client and a manager built against different revisions to interoperate.

4.21.1 What may be added #

A request field. A manager MUST ignore a request field it does not recognise (§4.8). A client MAY therefore send a field a manager may not know, and MUST NOT depend on the field having had an effect.

A response field. A client MUST ignore a response field it does not recognise, and MUST NOT treat its presence as an error. This includes an unrecognised member of summary in a reload-config response, and an unrecognised key in current_job or current_operation.

A notification field. A manager MUST ignore an unrecognised key (§4.17). A service MAY therefore send a field a manager may not know.

A type value in a status warning. A client MUST accept a warning whose type it does not recognise and MUST NOT discard it. An unclassifiable warning is still a warning.

A right in granted. A client MUST ignore a right name it does not recognise in a status's or a job view's granted (§4.14). A right it does not know of is one it offers no command for.

A command, together with the error codes and enumerated values that appear only in responses to it. A client is exposed to a command only by sending it, so a client that does not know a command is never exposed to anything it introduces. A manager MAY therefore add a command without a version, provided that nothing it introduces can reach a client through any existing command's response.

4.21.2 What may not be added without a version #

Anything a client must recognise in order to behave correctly cannot be added compatibly, because an older client's only options are to fail or to misbehave.

A manager MUST NOT, without a negotiated version:

  • introduce an error code outside §4.10 on an existing command;
  • introduce a service state, transition cause, operation state, operation type, operation source or job type outside §4.B, or a job state or cause outside §7.B, where an existing command could return it;
  • introduce a reload mode outside the three in §4.13;
  • change what an existing command does;
  • change the shape of an existing response, including changing a field's type or making a non-nullable field nullable.

A client encountering one of these has no correct behaviour available. Faced with an unknown state it cannot decide whether the service is running; faced with an unknown error code it cannot decide whether to retry.

4.21.3 What a client must do with the unknown #

A client MUST treat an unrecognised enumerated value in a field it depends on as an error for that request, and MUST NOT map it onto the nearest value it does know. Guessing that an unfamiliar state is probably like active is how a monitoring tool reports a broken system as healthy.

A client MUST treat an unrecognised error code as unrecoverable for that request. It MUST NOT retry, since it cannot know whether the condition is transient.

4.21.4 Versioning, when it comes #

A future revision introducing an incompatible change MUST do so through an explicit negotiation, in which a client states what it understands and the manager answers within that. Until such a mechanism exists, this chapter's contract is fixed and the rules above are the whole of the supported way for it to grow.

4.22 Conformance

Peios / Peios technical reference / PSPU / Service Control and Notification

4.22.1 A conforming manager #

The channels. Listens on a Unix stream socket at a well-known path and on a Unix datagram socket whose path it gives each service in NOTIFY_SOCKET. Ensures both sockets, and the directories containing them, carry a Security Descriptor admitting the parties intended to reach them, and relies on no POSIX mode bits (§4.3, §4.4, §4.16).

Framing. Emits exactly one compact JSON object per newline-terminated frame. Answers a malformed frame with MALFORMED_REQUEST and an oversized one with REQUEST_TOO_LARGE, closing the connection after a frame-level failure and holding it open after a command-level one (§4.5).

Identity. Obtains every client's identity from the kernel once, at accept, and uses no UID, GID or asserted identity (§4.6).

Authorisation. Checks every command against the appropriate Security Descriptor with the mappings in §4.7, naming the target in each check's audit context and giving every default descriptor a SACL that audits every refusal, writes no access-denied event of its own, filters list rather than denying it, and does not let operation-status distinguish an operation the caller may not see from one that does not exist. Reports a status's and a job view's granted from that same check asking for MAXIMUM_ALLOWED, and does not treat it as a denial (§4.14).

Commands. Implements all fourteen, with the outcomes in §4.12 for every command-and-state pair, the response shapes in §4.9, §4.14 and §4.15, and only the error codes in §4.10. Reports a boot as confirmed only once its attempt count has been put back to 0 (§4.15). Filters job-list by JOB_QUERY as it filters list, and answers job-status and job-stop against the job's own descriptor (§4.7, §4.14).

Operations. Returns an identifier from every lifecycle command that produced one and none where it did not; merges same-type requests and returns the surviving identifier; measures every operation's lifetime from its creation including queue time; and holds a terminal record for at least the grace period (§4.11, §4.14).

Waiting. Honours the per-command wait default, holds a waiting connection open past the idle timeout, and carries a mode on every reload response (§4.13).

Notification. Authenticates every datagram through all five steps of §4.18, including verifying the attested PID against a kernel handle and checking the activation generation for a service's job. Applies all lines of an accepted datagram and none of a rejected one. Rejects a truncated datagram rather than processing it. Implements every field in §4.19, retaining STATUS, PROGRESS and PROGRESS_UNIT and bounding how often a progress change becomes an event.

The descriptor store. Closes rather than keeps what it refuses; returns descriptors from 3 upward with LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID set; clears the store on a deliberate stop and keeps it across a restart the service did not ask for (§4.20).

Extension. Ignores unrecognised request and notification fields, and introduces nothing from §4.21's closed list without a negotiated version.

4.22.2 A conforming client #

Sends one compact JSON object per newline-terminated frame. Treats an immediate close with no response as a refusal. Does not parse message. Accepts null for every nullable field, and unrecognised fields everywhere it is told to. Treats an unrecognised enumerated value or error code as an error for that request rather than guessing. Reads state rather than the presence of error to decide whether an operation succeeded. Does not infer its own rights from an INVALID_STATE received during shutdown. Opens a new connection to act under a different identity.

4.22.3 A conforming service #

Reads NOTIFY_SOCKET from its environment and hardcodes no path. Sends READY=1 when it can genuinely serve, not when its process exists. Sends no recognised key with an undefined value, no newline inside a STATUS value, and no PROGRESS outside its three forms. Sends no datagram exceeding the bounds in §4.A. Expects no reply, and no acknowledgement that a field was applied. Treats EXTEND_TIMEOUT_USEC as replacing a deadline rather than adding to one. Checks LISTEN_PID against its own PID before adopting any descriptor.

4.22.4 What conformance is not #

A system that offers neither channel is still Peios (PSPU §1.2). These are contracts for the components that do offer them, not a bar the platform requires anything to clear.

Appendix 4.A Limits and Defaults

Peios / Peios technical reference / PSPU / Service Control and Notification

The values a Peios service manager uses. A manager MAY use different ones; where a value is configurable, it MUST be discoverable to an administrator through the same surface that sets it.

4.A.1 Control channel #

BoundValueConfigurableDefined in
Socket path/run/services/peinit/control.sockNo§4.4
Concurrent connections per caller16; SYSTEM exemptYes§4.4
Concurrent connections in all256Yes§4.4
Request size65536 bytes, excluding the terminating newlineYes§4.4
Idle timeout30 secondsYes§4.4
Listen backlog32No§4.4

4.A.2 Operations #

BoundValueDefined in
Terminal record retention60 seconds§4.14
Operation lifetimeThe target service's own start or stop timeout§4.11

4.A.3 Notification channel #

BoundValueDefined in
Maximum datagram65536 bytes§4.16
Descriptors per datagram64§4.16
First returned descriptor3§4.20
Descriptor store maximumPer service; 0 disables§4.20
Timeout extension cap4 × the phase's base timeout§4.19
Progress event rateAt most one per sender per second§4.19

4.A.4 Composing the two channels #

A STATUS value a service sends on the notification channel is returned as status_text on the control channel. The notification datagram bound is 65536 bytes and the control response is not bounded by the request limit, so a status string that fits in a datagram is always returnable.

The bounds are stated at their values here rather than left to each implementation because a producer has no other way to learn them. Lowering either without telling anyone breaks every service that was sizing to the old one, and the failure — a truncated datagram, or a connection closed mid-request — does not name its cause.

Appendix 4.B Wire Vocabulary

Peios / Peios technical reference / PSPU / Service Control and Notification

Every enumerated value that appears on the control channel. All are lower snake case. Except for status warning types and granted rights, these sets are closed: a manager MUST NOT emit a value outside them without the version negotiation in §4.21, and a client MUST treat one it does not recognise as an error for that request rather than mapping it onto a value it knows. The open sets state their handling rules below.

These sets are the control channel's. The same words appear in the manager's structured events in kebab-case, as every event value is (PGSS §6.5): explicit_start on the channel is explicit-start in an event.

4.B.1 Response status #

ok, error

4.B.2 Service state #

ValueProcess?Satisfies dependents?
inactiveNoNo
startingMaybeNo
activeYesYes
reloadingYesYes
stoppingBrieflyNo
completedNoYes
backoffNoNo
failedNoNo
abandonedYes, unkillablyNo
skippedNoYes

Exactly four states satisfy dependents: active, reloading, completed and skipped. A client deciding whether something depending on this service could be running MUST use that set and no other.

4.B.3 Transition cause #

explicit_start, dependency_start, restart_policy, binds_to_recovery, timer, explicit_stop, explicit_reload, explicit_reset, conflict_eviction, binds_to_propagation, shutdown_wave, process_crash, clean_exit, clean_exit_restart, readiness_timeout, watchdog_timeout, health_check_failure, pre_hook_failure, parent_setup_failure, pre_exec_failure, dependency_failure, restart_budget_exhausted, cycle_detected, validation_error, assertion_error, condition_skipped, tty_unavailable, process_unkillable, internal_error

A cause may also be null, for a service that has not transitioned.

4.B.4 Service health #

healthy, unhealthy, unknown

health is null when the service has no health check configured, which is distinct from unknown — the latter means one is configured and has not produced a result yet.

4.B.5 Job type #

service_main, pre_exec_hook, post_exec_hook, reload_hook, health_check, submitted

Only service_main appears in current_job. Only submitted appears in a job view (§7.7); a job's state and cause vocabularies are §7.B's.

4.B.6 Operation type #

start, stop, restart, reload, reset

4.B.7 Operation state #

ValueTerminal?Meaning
pendingNoQueued, not yet executing.
runningNoExecuting.
completedYesReached its goal.
failedYesDid not reach its goal, or expired while queued.
mergedYesMerged into another operation.
cancelledYesTerminated while pending. Never executed.
abortedYesTerminated while running.

4.B.8 Operation source #

admin, boot, shutdown, dependency_propagation, restart_policy, timer, binds_to_recovery, binds_to_propagation, conflict_resolution, on_failure, tty_release

admin is the only source a client's own command produces. The rest describe operations the manager created for its own reasons, and a client observing one has learned something about what the manager is doing rather than about anything it asked for.

4.B.9 Reload mode #

confirmed, advisory, failed

4.B.10 Status warning type #

service_tree, health, hooks, helper

These name what part of a service's process containment could not be reclaimed, service_tree being the whole of it and therefore the most serious. A client MUST accept a value outside this set and MUST NOT discard the warning (§4.21).

4.B.11 Granted right #

In a status's granted: query_status, start, stop, interrogate

In a job view's granted, on this channel: query, stop, signal

Each names a right of §4.7 or §7.8, lowercased and without its SERVICE_ or JOB_ prefix, and they appear in the order given here (§4.14). A client MUST ignore a name outside these sets rather than treat it as an error: an unrecognised right is one it has no command for, and the rights it does know are still correctly reported.

4.B.12 Shutdown type #

poweroff, reboot, halt

Request-only; the manager does not echo it.

4.B.13 Boot mode #

full, safe, recovery

The modes §4.15 defines. Only boot returns one, and a manager in recovery does not serve this channel, so in practice a client reads full or safe.

4.B.14 Boot mode reason #

ValueMeaning
normalA full boot, which is what a boot is unless something says otherwise.
requestedThe boot was asked for in this mode, on the kernel command line or its equivalent.
safe_mode_downgradeA full boot that the manager downgraded to safe because it found a fault it could not start the full configuration past, such as a critical service in a dependency cycle. downgrade lists each finding.

5.1 Scope and Roles

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

This chapter specifies the peipkg package format and the peipkg repository protocol: the artifact by which compiled software is distributed to a Peios system, and the static-HTTP protocol by which a system discovers, trusts, and fetches those artifacts.

A package is the binary distribution primitive of Peios — the unit of build, distribution, and trust. It is deliberately narrow: it defines how binaries reach a system, not how they are integrated into services, roles, or features. Higher-level artifacts reference packages; a package knows nothing of them.

5.1.1 Roles #

Three roles speak this specification. A requirement is stated against the role, not the program; one program may serve more than one.

RoleObligation
ProducerBuilds package files. Everything a .peipkg contains is a producer obligation.
RepositoryPublishes a descriptor, two indexes, and package files over static HTTP, and signs the metadata.
ConsumerFetches, verifies, and installs packages. Every validation and rejection rule binds the consumer.

A repository operator is usually also a producer, but need not be: a repository may publish packages built elsewhere, and the format's signatures survive the journey.

5.1.2 In scope #

  • The on-wire package file: container, internal layout, manifest schema, payload layout, per-file integrity
  • Package identity: names, versions, version comparison, architectures
  • How a package expresses its relationships to other packages, and what it means for one to satisfy another
  • Package signing: algorithm, envelope, verification
  • The repository protocol: descriptor, active and archive indexes, URL conventions, freshness and rollback protection
  • Establishing and maintaining trust in a repository
  • The rules under which the format may be extended

5.1.3 Out of scope #

  • How a consumer decides what to install. Given several candidates that all satisfy a dependency, which one it picks, in what order it applies a plan, and how it recovers from an interrupted one are the consumer's own design.
  • How a consumer stores its state. The installed-package database, its transaction journal, and its cache format are private.
  • How a producer builds a package. Recipes, build farms, and source trees are producer mechanics; only their output is specified here.
  • Roles, role features, core features, and applets. These are separate subsystems that reference packages.
  • Integration metadata attached to packages — service definitions, registry seeds, reconciller manifests. These belong to the artifacts that compose packages, not to packages.
  • Security descriptor semantics. A package carries security descriptor bytes; what they mean is specified with the kernel's access-control subsystem.

5.1.4 Relationship to other chapters #

Nothing in this chapter is a conformance requirement on a Peios system: a system that ships software some other way is still Peios (§1.1). What this chapter guarantees is that the format and the protocol are written down and will not move.

5.2 Terminology

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

  • Package — a binary distribution artifact: one or more files, metadata describing its identity and relationships, and, when signed, a signature. The unit of build, distribution, and installation.

  • Manifest — the JSON document at .peipkg/manifest.json inside a package that declares its identity, relationships, side-effect requirements, and build provenance. The manifest is authoritative for a package's metadata (§5.18).

  • Files manifest — the JSON document at .peipkg/files.json carrying one content hash per regular payload file (§5.25).

  • Payload — the tar entries of a package that are not metadata: the files, directories, and symlinks it installs.

  • Repository — a collection of packages addressable as a unit, identified by its base URL.

  • Repository descriptor — the small JSON document at a well-known path within a repository declaring its identity, signing keys, and the locations of its indexes (§5.31).

  • Index — a signed JSON document listing packages available from a repository. Every repository publishes two: an active index (§5.33) listing the current version of each package, and an archive index (§5.35) listing every version ever shipped.

  • Virtual name — a capability name, rather than a package name, that a package may require or provide (§5.4).

  • Role — a virtual name that several installed packages may contend to own on the filesystem, with at most one holding it (§5.23).

  • Claim — the binding of a contended filesystem name (a claim path) to a file supplied by the package that holds a role (a target).

  • Holder — the single installed package that currently owns a role. A role with no holder is unheld.

  • Side-effect declaration — a manifest flag naming a standard maintenance operation to be invoked after install, drawn from a closed set (§5.24).

  • Installation root — a self-contained filesystem tree into which packages are installed. The default root is the system root; a system may define others (§5.19).

  • Epoch, upstream version, peios revision — the three components of a version string (§5.5).

  • Trust anchor — a key fingerprint supplied to a consumer out-of-band, against which a repository's descriptor signature is first verified (§5.37).

5.3 Package Names

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package's name identifies it within a repository and across every repository that may serve it.

5.3.1 Character set #

A package name MUST consist of ASCII characters drawn from:

  • lowercase letters a–z
  • digits 0–9
  • hyphen -
  • period .
  • plus sign +

A package name MUST NOT contain uppercase letters, whitespace, underscores, or any character outside that set.

5.3.2 Structure #

A package name MUST start with a lowercase letter or a digit, and MUST end with a lowercase letter, a digit, or a plus sign.

The hyphen and the period are separator characters. The plus sign is not a separator but an ordinary name character: it is intrinsic to names such as libstdc++ and g++, so it MAY repeat and MAY end a name.

A package name MUST NOT contain two consecutive separators — --, .., -., or .-.

A package name MUST be at least 2 and at most 64 characters long.

Note

The character set admits the common upstream patterns: library suffixes (libstdc++), architecture prefixes (lib32-foo), and dotted module names (python3.example). Underscores are excluded so that the filename separator below stays unambiguous.

5.3.3 Case #

Package names are case-sensitive. Because uppercase letters are forbidden, this is equivalent to byte-for-byte equality.

5.3.4 Filename convention #

A package file's name, on disk and in URLs, MUST be:

<name>_<version>_<architecture>.peipkg

The separator between fields is the underscore, and the extension is .peipkg.

A filename is parsed by splitting at the first underscore and then at the second: what precedes the first is the name, what lies between them is the version, and what follows the second — up to the .peipkg extension — is the architecture. The underscore MUST NOT appear in the name (§5.3) or in the version (§5.5). It MAY appear in the architecture (§5.8), and does in x86_64, which is why the architecture field is defined as the remainder rather than as the text after the last underscore.

Examples:

nginx_1.26.2-3_x86_64.peipkg
jq_1.7.1-2_x86_64.peipkg
peios-docs_0.22-1_noarch.peipkg
libstdc++_13.2.1-4_x86_64.peipkg

A consumer MUST NOT derive a package's identity from its filename. The manifest is authoritative (§5.18); the filename is a convenience for humans and for static hosting.

5.3.5 Sub-package conventions #

Packages shipping related but separable content SHOULD use a hyphen-suffix convention:

SuffixContent
-docDocumentation, man pages, examples
-debugDebug symbols
-devHeaders, static libraries, build-time dependencies
-sourceCorresponding source (§5.14)

These are advisory. The format does not enforce them, and other suffixes MAY be used for other purposes.

5.4 Virtual Names

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

The name of a dependencies, optional_dependencies, or provides entry (§5.21) MAY be a virtual name rather than a real package name. A virtual name expresses a capability that is required or provided but is not itself a package — most importantly a machine-derived capability such as an ELF soname or a pkg-config module (§5.22).

conflicts and replaces entries target real packages, and so MUST use the package-name grammar of §5.3, not the grammar below.

5.4.1 Grammar #

The virtual-name grammar is a strict superset of the package-name grammar, in two respects.

Uppercase letters are permitted. A virtual name often mirrors an exact machine identifier — libGL.so.1, libICE.so.6, a foreign module name — which is case-sensitive. Case MUST be preserved: folding it would be unsound, because a case-sensitive dynamic loader treats libGL.so.1 and libgl.so.1 as distinct.

A namespaced form namespace(argument) is permitted, for capabilities drawn from a foreign namespace. The namespace is lowercase letters and digits, beginning with a letter. The argument is bracketed by parentheses, is non-empty, and may contain letters, digits, the separators -, ., +, and additionally _, :, and / — so that pkgconfig(gtk+-3.0), perl(Foo::Bar), and python3dist(ruamel.yaml) are all well-formed.

Outside the namespaced form, a virtual name uses the package-name character set extended with the underscore _, which is common in real sonames (libgcc_s.so.1, libnss_files.so.2). It MUST start with a letter or a digit and MUST end with a letter, a digit, or +. Unlike a package name, a virtual name MAY contain consecutive separators, so that libstdc++.so.6 is well-formed.

A virtual name MUST be at least 2 and at most 128 characters long.

5.4.2 One namespace #

Virtual names share a namespace with real package names. A dependency on libssl is satisfied by a package literally named libssl, or by any package whose provides includes libssl.

The namespaced form exists to keep machine-derived capabilities from colliding with package names: pkgconfig(zlib) is unambiguously the pkg-config module, never a package.

5.5 Versions

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every package carries a version string that identifies one build of that package. Version strings have a defined structure and a defined comparison order (§5.6), so that "newer" and "older" are unambiguous across every implementation.

5.5.1 Structure #

[<epoch>:]<upstream>-<peios_revision>
  • Epoch — an OPTIONAL non-negative integer, separated from the rest by a colon. Absent means zero.
  • Upstream — the version the upstream project assigned, or, for Peios-native software, the version Peios assigned as vendor.
  • Peios revision — a REQUIRED positive integer identifying the build of this upstream version produced by the distributor.
1.26.2-3            upstream 1.26.2, revision 3
1.26.2-rc.1-1       upstream 1.26.2-rc.1, revision 1
2:0.5.0-1           epoch 2, upstream 0.5.0, revision 1
0.22-1              upstream 0.22, revision 1 (Peios-native)

5.5.2 Epoch #

The epoch MUST be encoded as ASCII decimal digits with no leading zeros, except that zero is encoded as the single digit 0. The separator is a single colon.

Epoch exists solely to override the natural ordering of upstream version strings when an upstream regression makes a later release compare as older than an earlier one. Bumping it SHOULD be a deliberate, documented decision; a routine version update MUST NOT bump it.

Note

An upstream project releases v2.0, abandons that line, and releases v0.5 as its new stable branch. Without an epoch, v0.5 compares as older than v2.0 and nobody on v2.0 can upgrade. Bumping the epoch to 1 says "v0.5 in this epoch is newer than anything in epoch 0".

5.5.3 Upstream version #

The upstream version is everything between the optional epoch separator and the final hyphen preceding the revision.

It MUST consist of ASCII characters drawn from: letters a–z and A–Z, digits 0–9, period ., plus sign +, hyphen -, and tilde ~. It MUST start with a digit or a letter, and MUST NOT contain whitespace or any character outside that set.

Note

The set is permissive because upstream projects format versions in every way imaginable: numeric (1.26.2), hyphenated pre-release (1.0.0-rc.1), concatenated pre-release (16beta1), build metadata (1.0+build.42), and tilde-separated pre-release (1.0~rc.1).

5.5.4 Peios revision #

The peios revision MUST be a positive integer encoded as ASCII decimal digits with no leading zeros. It is incremented when the distributor produces a new build of the same upstream version — a backported security patch, a build-configuration change, a dependency bump, a packaging fix.

The first revision of any upstream version MUST be 1. Revision 0 is reserved and MUST NOT appear in a published package.

5.5.5 Parsing #

A version string is parsed as follows:

  1. If the string contains a colon, split at the first colon: what precedes it is the epoch, what follows is the remainder. Otherwise the epoch is 0 and the remainder is the whole string.
  2. Split the remainder at the last hyphen: what follows is the peios revision, what precedes is the upstream version.
  3. The peios revision MUST parse as a positive integer.
  4. The upstream version MUST satisfy the constraints above.

A version string that does not parse is invalid, and an implementation MUST reject it.

5.5.6 Stability #

The comparison algorithm of §5.6 is frozen. Any two conforming implementations MUST produce identical comparison results for every pair of valid version strings. An implementation that disagrees with another on any such pair is non-conformant, whichever of the two is at fault.

5.6 Comparing Versions

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Two version strings are compared in three stages:

  1. Compare epochs as integers. If they differ, the higher epoch is greater.
  2. If equal, compare upstream versions by the algorithm below.
  3. If equal, compare peios revisions as integers. The higher revision is greater.
  4. If all three are equal, the versions are equal.

5.6.1 Tokenising the upstream version #

A tokeniser walks the upstream string left to right and emits segments:

  1. The non-alphanumeric characters ., +, -, and ~ are separators and belong to no segment.
  2. A maximal run of digits forms a numeric segment.
  3. A maximal run of letters forms an alphabetic segment.
  4. A transition between a digit and a letter ends the current segment and begins a new one.

5.6.2 Pre-release segments #

A segment is a pre-release segment if it falls at or after the earlier of:

  • the first ~ separator — the tilde and every segment following it; or
  • the first recognised pre-release token: a segment whose token carries a rank of 0 to 4 in the table below, that segment and every segment following it.

Once the pre-release tail begins it extends to the end of the upstream version: every later segment is a pre-release segment, whatever the separators between them. A - separator is an ordinary separator; it is not itself a pre-release marker.

Note

In 1.0.0-rc.1, rc is recognised at rank 4, so rc and the following 1 are pre-release. In 16beta1, beta is recognised at rank 2, so beta and 1 are pre-release with no separator involved. In 1.0-foo, foo is alphabetic but not recognised: it is an ordinary rank-5 segment, and the - before it begins nothing.

UpstreamSegments
1.26.21, 26, 2
1.0.0-rc.11, 0, 0, rc (pre), 1 (pre)
1.0~rc11, 0, rc (pre), 1 (pre)
16beta116, beta (pre), 1 (pre)

5.6.3 Pre-release rank #

TokenRank
dev0
alpha1
a1
beta2
b2
pre3
rc4
any other alphabetic token5

Rank 0 sorts lowest. Rank lookup MUST be case-insensitive: Alpha, ALPHA, and alpha all carry rank 1.

5.6.4 Comparing two segments #

The pre-release flag is compared first, before the kinds. If exactly one of the two segments is a pre-release segment, that segment is the lesser, whatever either segment contains. A pre-release segment sits at or after the point where the version was marked as preceding a release, and that is a property of position rather than of content.

When both segments carry the same flag — both pre-release, or neither — their kinds decide:

  1. Both numeric — compare as integers. Leading zeros are insignificant.
  2. Both alphabetic — compare by pre-release rank. When the ranks are equal:
    • at a rank of 0 to 4, the segments are equivalent. The table assigns several tokens to one rank as aliases, so two segments at the same recognised rank sort equal whichever alias appears.
    • at rank 5, the segments tiebreak by ASCII byte order against other rank-5 tokens.
  3. One numeric, one alphabetic — the alphabetic segment is the lesser if the pair is a pre-release pair, and the greater if it is not. (Where only one of them is a pre-release segment, the rule above has already decided.)

Note

1.0~alpha-1 == 1.0~a-1       # both rank 1
1.0~beta-1  == 1.0~b-1       # both rank 2
1.0~Alpha-1 == 1.0~ALPHA-1   # case-insensitive recognition
1.0-foo-1   <  1.0-zzz-1     # rank-5 lexical tiebreak

Two upstream versions that differ only in which alias they use are equal. A repository publishing both forms produces archive entries at the same logical version; the active index may carry either. A producer SHOULD pick one canonical form — conventionally the long token, alpha and beta — and keep to it.

5.6.5 Unequal lengths #

When the segments of one version run out and every common segment compared equal, the next segment of the longer sequence decides. Its pre-release flag decides it, and its kind is irrelevant:

Next segment in the longerResult
a pre-release segmentthe shorter is greater
anything elsethe shorter is less
Example tailResult
~1numeric, pre-releasethe shorter is greater
~rcalphabetic, pre-releasethe shorter is greater
.1numericthe shorter is less
-fooalphabetic, rank 5the shorter is less

5.6.6 Worked examples #

ABResultWhy
1.0~21.0-2A < Bthe pre-release flag decides before the kinds
1.0~foo1.0-fooA < Bthe same, for two rank-5 tokens
1.01.0A = Bidentical
1.02.0A < Bnumeric segment differs
1.101.9A > Bnumeric, not lexical
1.01.0.1A < Blonger continues numerically
1.01.0-rc.1A > Blonger continues with a pre-release
1.0-rc.11.0-rc.2A < Bnumeric segment within the tail
1.0-alpha1.0-betaA < Brank 1 < rank 2
1.0-rc1.0-preA > Brank 4 > rank 3
1.0a11.0a2A < Bnumeric within a concatenated tail
1.0a11.0b1A < Brank 1 < rank 2
1.0~rc11.0A < Bthe tilde forces a pre-release
1.0~11.0A < Bthe tilde forces a pre-release, numeric or not
5.2~202401015.2A < Ba dated snapshot precedes its release
0:1.01:0.5A < Bepoch dominates
1.0-11.0-2A < Bpeios revision differs
1.0-foo-11.0-1A > Bfoo is rank 5, sorting after a number

5.7 Version Constraints

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A version constraint restricts which versions of a package satisfy a relationship (§5.21).

5.7.1 Operators #

OperatorMeaning
=exactly equal
>strictly greater than
>=greater than or equal
<strictly less than
<=less than or equal
!=not equal

Comparison is by §5.6 in every case.

A bare version string with no operator is equivalent to =.

5.7.2 Combining #

Multiple expressions within one constraint string are separated by commas and combined with logical AND. A version satisfies the constraint if and only if it satisfies every expression.

libssl >= 3.0
libssl >= 3.0, < 4.0
nginx = 1.26.2-3

Whitespace around operators and commas is optional and MUST be ignored.

A constraint string MUST parse as one or more operator-and-version expressions separated by commas. One that does not parse is invalid, and an implementation MUST reject it.

5.7.3 Revision-relaxed operands #

A constraint's version operand MAY omit the -<revision> that a complete version string otherwise requires.

An operand written without a revision — >= 3.0 — constrains the epoch and the upstream version only. A candidate satisfies it whenever its epoch and upstream version satisfy the operator, whatever its revision.

An operand written in full constrains the revision as well.

Note

This is what lets a dependency track a capability level rather than a packaging iteration. libssl >= 3.0 is satisfied by 3.0-1 and by 3.0-7 alike; nothing about a repackaging of the same upstream release should change whether a dependency is met.

5.8 Architectures

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package's architecture identifies the instruction-set architecture its binaries were built for. It is a separate identifier from the name and the version.

5.8.1 Identifier format #

An architecture identifier MUST consist of lowercase letters a–z, digits 0–9, and the underscore _. It MUST start with a lowercase letter and MUST NOT exceed 16 characters.

5.8.2 Defined architectures #

IdentifierMeaning
x86_6464-bit x86 (AMD64, Intel 64)
aarch6464-bit ARM (ARMv8-A or later)
noarcharchitecture-independent

An implementation MUST recognise all three. x86_64 is the primary target; every other architecture is secondary in this version of the specification.

Additional identifiers MAY be defined in a future version. A new identifier MUST satisfy the format above and SHOULD be the canonical Linux machine name — the value uname -m reports — where one exists.

5.8.3 Triplets #

Each architecture identifier that is not noarch has a corresponding triplet, used in the install paths where arch-specific content is namespaced (§5.15):

<identifier>-linux-peios
IdentifierTriplet
x86_64x86_64-linux-peios
aarch64aarch64-linux-peios

noarch has no triplet form, and architecture-independent payload MUST NOT be installed under an arch-namespaced path.

Note

The peios suffix distinguishes Peios binaries from foreign-arch binaries originating elsewhere — Debian uses gnu, Alpine uses musl. It leaves room for a future multi-architecture system to host foreign-distribution binaries without filesystem-path collisions.

5.8.4 Architecture-independent packages #

The noarch identifier denotes a package whose payload contains no architecture-dependent content: documentation, configuration templates, scripts in interpreted languages, or metadata only.

A package MUST NOT declare noarch if its payload contains compiled binaries, shared libraries, or any other content whose semantics depend on the target architecture.

5.8.5 Installability #

Each Peios system has a single primary architecture, fixed at install time.

  • A package whose architecture equals the system's primary architecture MAY be installed.
  • A package whose architecture is noarch MAY be installed on any system.
  • A package whose architecture is neither MUST NOT be installed.

Note

This version of the specification does not define multi-architecture systems — systems installing foreign-architecture packages alongside native ones. Running foreign-architecture binaries for emulation, cross-compilation, or legacy compatibility is addressed by mechanisms outside the package format. The triplet convention above applies regardless, so that a package conforming to this version stays forward-compatible with such an extension.

5.9 Document Conventions

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every artifact this chapter defines — the manifest, the files manifest, the signature envelope, the repository descriptor, and both indexes — is a JSON document. The rules below apply to all of them.

5.9.1 JSON #

Documents conform to RFC 8259 and are UTF-8 encoded (RFC 3629). Field names are lowercase with underscores between words: schema_version, never schemaVersion. Field order is not significant.

Unknown fields MUST be ignored on parse, so that the format can be extended compatibly (§5.38). The signature envelope (§5.28) is the one exception, and mandates strict parsing.

5.9.2 Parser hardening #

A consumer's JSON parser processes attacker-supplied input. It MUST therefore enforce the following, on every document defined in this chapter:

  • Duplicate keys in any object MUST cause the document to be rejected. A parser that silently takes first-wins or last-wins is not conformant.
  • Integer fields MUST fit in the unsigned 64-bit range and MUST NOT use exponent notation.
  • Nesting depth MUST be capped at 64; a document exceeding that depth MUST be rejected.
  • A string value MUST NOT exceed the document size limit applicable to its containing artifact (§5.A).
  • A Unicode escape within a string MUST resolve to a valid code point per RFC 8259 §7.

Note

Duplicate-key rejection is the load-bearing rule of the list. A parser that takes last-wins and a parser that takes first-wins disagree about what a document says, which means the bytes a producer signed and the bytes a consumer acts on can differ while every signature still verifies. Several widely used JSON libraries take last-wins silently; conformance requires the rejection to be added deliberately.

5.9.3 Hashes #

Hash values are encoded in lowercase hexadecimal unless stated otherwise. Hash algorithms are identified by their IANA-registered names (sha256, blake3).

5.9.4 Signatures #

Signatures use Ed25519 as defined in RFC 8032 unless stated otherwise. Signature values are encoded in base64 (RFC 4648 §4) without padding. A base64 value carrying padding MUST be rejected.

5.9.5 Strings #

String comparison uses byte-for-byte equality unless stated otherwise.

5.9.6 URLs #

URLs follow RFC 3986. Relative URLs in a repository index are resolved against the repository descriptor's URL (§5.36).

5.9.7 Compression #

Compression uses the Zstandard format (RFC 8478). This specification does not constrain the compression level.

5.10 The Container

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package is a single file: a tar archive compressed with Zstandard.

5.10.1 Extension #

A package file's extension MUST be .peipkg. There is no intermediate .tar form; a producer emits the compressed form directly, and the compressed file is the whole artifact.

5.10.2 Tar format #

The tar archive MUST conform to the POSIX pax interchange format (IEEE Std 1003.1-2017, Chapter 14).

Note

pax supports arbitrary path lengths through extended headers. The older ustar format limits paths to 100 characters, or 255 with a prefix, which is not enough for some real packages. GNU tar, libarchive, and BSD tar all read and write pax by default.

5.10.3 Compression #

The archive MUST be compressed with Zstandard (RFC 8478).

The compression level is at the producer's discretion. zstd is deterministic at every level, so a producer MAY choose any level to trade build time against on-wire size. Levels 19 and above, including --ultra, increase build time substantially for a smaller result; level 3 is a common default.

5.10.4 Reproducibility #

A package MUST be reproducible: given identical source inputs, an identical build environment, identical metadata — including the build timestamp recorded in the manifest — and identical compression parameters, two independent producers MUST produce byte-identical package files.

The determinism rules of §5.11 are what make this achievable at the format level. They are necessary rather than sufficient: they constrain what the archive looks like, not how the producer arrived at its contents.

Byte-identity is a property of the uncompressed tar stream and of the compression applied to it. This specification fixes the former completely and the latter not at all: the compression level, the Zstandard implementation, its version, and its frame parameters all affect the resulting bytes and are none of them constrained here. Two producers seeking byte-identical output MUST therefore agree on their compression parameters out of band. What the format guarantees unconditionally is that the signed bytes — the uncompressed tar prefix of §5.28 — are identical, so a signature survives recompression at any level.

5.10.5 Streaming #

A consumer MAY process the archive as a stream. The internal layout (§5.12) places metadata before payload precisely so that a consumer can read a package's identity and reject a mismatched package without buffering the payload.

5.10.6 No outer wrapping #

The compressed archive contains tar entries and nothing else: no enclosing directory, no concatenated archives, no container metadata outside the tar entries themselves.

5.11 Determinism

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

To make a package byte-reproducible (§5.10), the tar archive MUST obey every rule below. A consumer MUST reject a package that violates any of them.

  1. Tar entries MUST be ordered lexicographically by the entry name as written into the tar header, compared byte-for-byte over its UTF-8 bytes. A directory's entry name carries a trailing /, and that slash participates in the comparison.
  2. Every entry's modification time MUST equal the value of build.timestamp in the manifest (§5.18), which MUST NOT carry sub-second precision.
  3. Every entry's owner numeric ID and group numeric ID MUST be 0.
  4. Every entry's owner name and group name MUST be the string root.
  5. Entries MUST NOT carry extended attributes. Security descriptors are applied at file-creation time (§5.20), never through tar attributes; a binary signature travels as a detached-signature payload entry (§5.16), from which the consumer derives the attribute; other install-time attributes are applied through side-effect declarations (§5.24) or by higher-level mechanisms outside this specification.
  6. Entry permission bits MUST be 0777 for every entry, with the setuid and setgid bits cleared (§5.16).
  7. PAX extended header records, when present, MUST appear in a fixed canonical order: path first, then linkpath if present, then any other record sorted by record name lexicographically.
  8. The tar header magic MUST be ustar\0 and the version MUST be 00.
  9. The devmajor and devminor header fields MUST be 0 for every entry type this specification permits — none of which is a device entry.
  10. Header padding bytes MUST be NUL (0x00).
  11. PAX global header records (typeflag g) MUST NOT appear.
  12. PAX extended header records (typeflag x) MUST appear only when an entry's path exceeds the ustar 100-byte limit, in which case a path record is emitted, or its linkname exceeds that limit, in which case a linkpath record is emitted. A record with any other key MUST NOT be emitted.
  13. A path exceeding the ustar 100-byte limit MUST be carried by a path record. The ustar prefix field MUST NOT be used to split such a path across prefix and name.
  14. An extended header entry's own name MUST be the containing directory's path, then PaxHeaders.0/, then the base name of the entry it describes. For an entry at the archive root the directory part is absent.

Rules 13 and 14 exist because a tar library given a long path may legitimately choose either encoding, and either choice produces a different byte stream from the same input. Determinism requires the choice be made here rather than by whichever library a producer reached for.

Note

Rule 2's sub-second prohibition is not fussiness. A timestamp carrying a fractional part forces an mtime extended header onto every entry, violating rule 12 across the whole archive and changing the block count of the signature entry's own header — which is exactly the quantity a verifier uses to find the end of the signed range (§5.28). The symptom is a signature that does not verify, reported against the key rather than against the timestamp.

Note

Rules 3, 4, and 6 together make a payload identity-free and permission-free: bytes in transit, carrying no claim about who may read them. What access control an installed file gets is decided at install time (§5.20), not declared by the package. §5.16 states the consequence for anyone extracting a package with a generic tar tool.

5.12 Internal Layout

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package's tar entries divide into metadata entries under a reserved prefix and payload entries — the files that will be installed.

5.12.1 The reserved prefix #

Every metadata entry MUST appear under the path prefix .peipkg/ at the archive root. That prefix is reserved: a payload entry MUST NOT use any path beginning with .peipkg/, and no payload entry may be named literally .peipkg.

5.12.2 Required entries #

Entry pathPurposeSection
.peipkg/manifest.jsonAuthoritative package metadata§5.18
.peipkg/files.jsonPer-file integrity manifest§5.25
.peipkg/signatureInline package signature§5.28

.peipkg/manifest.json and .peipkg/files.json MUST be present in every package. .peipkg/signature MUST be present in every signed package; a package without it is unsigned (§5.28).

5.12.3 Entry order #

Tar entries MUST appear in exactly this order:

  1. .peipkg/manifest.json
  2. .peipkg/files.json
  3. Any optional metadata entries, sorted lexicographically by path
  4. All payload entries, sorted lexicographically by path (§5.11 rule 1)
  5. .peipkg/signature

The manifest comes first so that a streaming consumer can read a package's identity and reject a mismatched one — wrong name, wrong version, wrong architecture — before reading any payload.

The signature comes last because it signs everything preceding it (§5.28). A consumer MUST reject a package in which any named entry follows .peipkg/signature.

5.12.4 Optional metadata entries #

A package MAY carry additional entries under .peipkg/. The set this specification recognises is fixed at the three above; an unrecognised entry under .peipkg/ MUST be ignored on parse and MUST NOT prevent installation.

When present, an optional metadata entry MUST appear between .peipkg/files.json and the first payload entry.

Note

A future version may introduce build attestations, reproducibility manifests, or a software bill of materials as further metadata entries. A producer targeting that future version MAY emit them into a package conforming to this one; a consumer conforming to this version ignores them.

5.12.5 Permitted entry types #

A payload entry MUST be one of:

  • a regular file (typeflag 0 or \0)
  • a directory (typeflag 5)
  • a symbolic link (typeflag 2)

Any other entry type MUST cause the package to be rejected. This excludes hardlinks (typeflag 1), character devices (3), block devices (4), FIFOs (6), contiguous files (7), and every vendor-specific type.

Note

Hardlinks are excluded because they share an inode with their target, which would let a package install a payload entry aliasing an existing system file and so gain shared access to it. Kernel hardlink-creation permissions provide some defence, but excluding the entry type at the format level is simpler and removes the class entirely. Device, FIFO, and contiguous entries have no use in a package payload.

5.13 Payload Paths

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A payload entry's tar path is its install location, resolved against the installation root (§5.19). A tar entry at usr/bin/nginx installs to /usr/bin/nginx.

5.13.1 Constraints #

A payload path MUST:

  • be relative — it MUST NOT begin with /
  • contain no segment equal to . or .., or any encoding thereof
  • be valid UTF-8 (RFC 3629)
  • contain no NUL byte (0x00) and no ASCII control character (0x01–0x1F, 0x7F)
  • contain no backslash (\)
  • be in Unicode Normalization Form C, per Unicode 16.0
  • have every component at most 255 bytes when encoded as UTF-8
  • be at most 4096 bytes in total when encoded as UTF-8
  • have at most 256 components
  • not begin with .peipkg/, and not be literally .peipkg (§5.12)

A consumer MUST validate every payload path against these constraints before any further processing of the entry. A package containing a non-conforming payload path MUST be rejected.

5.13.2 No canonicalisation #

Path resolution MUST NOT canonicalise away .. or . segments by interpretation. Such segments are forbidden above; any appearance is a format error, not a question of path canonicalisation.

Note

The distinction matters. A consumer that normalises a/../b to b and proceeds has accepted a path this specification forbids, and has done so by a rule the producer never agreed to. Rejecting is the only behaviour both sides can predict.

5.13.3 Empty payloads #

A package MAY have zero payload entries. Such a package carries only metadata; installing it records the package and runs any declared side effects (§5.24).

Note

An empty-payload package is useful as a virtual aggregator: it ships no binaries of its own and exists to declare dependencies on a curated set of other packages.

5.14 Install Destinations

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Peios separates package-owned vendor storage under /usr from the root-level runtime views such as /bin, /lib, and /sbin. Those views are filesystem topology assembled by the boot and base-filesystem layers; they are not package storage. A package installs its files under /usr, and the runtime topology projects them at their canonical paths.

Within /usr, executables split by kind. /usr/sbin/ holds system binaries — daemons, init and boot binaries, and service executables not normally invoked directly by a person. /usr/bin/ holds everything else, including administrative tools a person does invoke directly, even those requiring administrator privileges.

5.14.1 Permitted top-level destinations #

PathPurpose
/usr/bin/Executables that are not system binaries — user-facing tools, and admin tools invoked directly
/usr/sbin/System binaries — daemons, init and boot binaries, service executables
/usr/lib/<triplet>/Architecture-specific libraries and arch-dependent data (§5.15)
/usr/lib/debug/Separated debug information, mirroring the install paths of the files it describes
/usr/lib/modules/<release>/Kernel content for one kernel release: its modules, and the kernel image, System.map, and build config alongside them
/usr/lib/firmware/Device firmware blobs, addressed by device rather than by host triplet
/usr/lib/os-releaseThe freedesktop OS-identity file, at a fixed external contract path
/usr/libexec/Architecture-independent helper executables run by another program rather than by a person
/usr/share/Architecture-independent data
/usr/include/Header files
/usr/src/debug/Debugger source files, mirroring the build's source tree
/usr/src/dist/Corresponding source shipped by -source packages
/usr/etc/Vendor-shipped default configuration for legacy applications — the bottom layer of the /etc merge
/usr/conf/Vendor-shipped defaults for the supplementary configuration of native applications — the bottom layer of the /conf merge
/var/Runtime variable state directories, empty at install time
/boot//boot/initramfs/, a complete independent root filesystem, and /boot/efi/, the EFI System Partition
/hooks/Initramfs boot hooks, discovered and ordered when the initramfs cpio is packed
/++/Initramfs early-cpio segments, prepended uncompressed ahead of the main archive

A payload entry MUST NOT install under any other top-level path, unless the package declares itself a special system package (below).

A consumer MUST enforce this at install time. Producer-side validation proves nothing about a package file that arrives from elsewhere.

5.14.2 Notes on individual destinations #

Only the debug/ and dist/ subtrees of /usr/src/ are permitted; the rest of /usr/src/ is administrator territory.

/usr/etc/ is where package configuration goes. A package never writes /etc directly, because /etc is a merged view resolving /usr/etc < /system/retc < /lcl/etc, not storage. /usr/conf/ is the equivalent bottom layer of the /conf merge (/usr/conf < /lcl/conf); native software reads the registry directly, so there is no reconciled layer between them.

/var/ accepts empty directories only, establishing locations a runtime will write to — /var/log/<service>/, /var/state/<service>/. Populated content under /var/ is invalid: variable state is owned by the runtime, not by the package.

/hooks/ is meaningful only in an initramfs root, where the cpio packer scans it. In an ordinary system root it is an unused permitted destination.

An entry under /boot/ SHOULD be a symlink whose target resolves to a regular file under one of the other permitted destinations — typically /usr/lib/<triplet>/ for a kernel image, initramfs, or device tree. /boot/ is a discovery directory a bootloader reads, not storage where real package content lives. This is a SHOULD rather than a MUST because recovery images and embedded bootloader integrations that cannot follow symlinks exist; a format-level validator does not enforce it.

A package reaching /boot/initramfs/ is cross-targeting a different root, not installing into this one (§5.19).

Note

What is absent from the list, and why:

  • /bin, /sbin, /lib, /lib64, /libexec, /share, /include, /etc, /conf — merged views, computed from the trees beneath them so that software sees one path while authority stays separated. A package writes the /usr layer and the view does the rest.
  • /lcl — the operator's tree, the peer to /usr that is backed up and survives reinstall. /lcl/policy in particular holds inputs that grant authority; writing there yields arbitrary code at boot, which is why packages are excluded structurally rather than by rule.
  • /system — derived from the image, registry, or platform, and always reconstructible.
  • /opt — operator territory, deliberately off the list. Software there brings its own layout, is invisible to package verification, and participates in none of these guarantees.
  • /dev, /proc, /sys, /run, /tmp — kernel interfaces and runtime-only directories; not storage a package can populate, not even as empty directories.
  • /srv, /data, /home, /media, /mnt — operator and user namespaces.
  • /usr/local — does not exist. /usr is meant to be untouchable, and a writable subdirectory of it makes that a lie; /lcl is the honest version.
  • /root — does not exist. There is no root.

5.14.3 Special system packages #

A few packages exist precisely to lay down the structure these rules protect — the base-filesystem package that mints the runtime mountpoint tree is the archetype. For those, the allowlist is not a guardrail but the thing being installed.

Such a package MAY set special_system_package in its manifest (§5.18). The declaration waives the layout checks at production time only. It grants nothing at install time: a consumer MUST refuse an out-of-layout payload unless the operator has also explicitly opted in, through a distinct and deliberate act naming that intent.

This is two keys held by two parties. A package may propose its own exemption; only whoever installs it can grant one.

When a consumer meets the declaration without having been given the opt-in, it MUST refuse the package with an error naming the refused request, so that an operator can tell "this package asked for an exemption I did not grant" from "this package is malformed".

/lcl/policy MUST NOT be reachable by this route under any circumstance. It is the tree whose contents grant authority, and an exemption that could reach it would convert a structural guarantee into a policy one.

5.14.4 Drop-in directories #

Several subdirectories of the /etc merge are drop-in directories: their contents are interpreted as code or configuration by other tools, notably the side-effect tools of §5.24 and system daemons that read configuration drop-ins. A package writing into one has indirect influence on the behaviour of components that read it.

A package from a repository other than the system's official repository MUST NOT install a file at the top level of the /usr/etc layer of any of these:

  • ld.so.conf.d/
  • profile.d/
  • sudoers.d/
  • cron.d/, cron.daily/, cron.hourly/, cron.weekly/, cron.monthly/
  • sysctl.d/
  • modules-load.d/
  • modprobe.d/
  • binfmt.d/
  • any directory the system declares as a drop-in directory through its configured list

The consumer's drop-in directory list MUST be stored under a security descriptor granting write access only to a recovery-class operator principal, never to the principal performing installs. Operator configuration MAY add entries to the list but MUST NOT remove an entry this specification requires: the list is purely additive.

A non-official-repository package whose payload installs to one of those paths MUST be rejected at install time.

A non-official-repository package MAY install drop-in files under its own subdirectory of a drop-in path — for example /usr/etc/ld.so.conf.d/<repo-name>/<package>.conf — provided the subdirectory is namespaced by both the repository's name and the package's name, so that two such packages cannot collide.

Note

The restriction closes a concrete chain. A low-trust repository installs a configuration file at the top level of ld.so.conf.d/ that extends the loader's library search path. At the next transaction that declares ldconfig — from any repository, not necessarily the same one — the drop-in is honoured and the loader's behaviour changes. Restricting top-level drop-in writes to the official repository, with a namespaced escape hatch for legitimate non-official content, breaks the chain without forbidding the use case.

5.15 The Architecture Triplet

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package whose architecture is not noarch MUST install all of the following under /usr/lib/<triplet>/, where <triplet> is the architecture triplet of §5.8:

  • shared libraries (.so, .so.*)
  • static libraries (.a)
  • loadable modules — plugin shared objects, and kernel modules outside /usr/lib/modules/
  • architecture-dependent helper binaries not on the user's search path
  • any other arch-dependent file that is not a user-facing binary

Architecture-independent helper executables — a shell script run by another program, say — go under /usr/libexec/ instead, which carries no triplet rule because the rule is scoped to /usr/lib/.

A package whose architecture is noarch MUST NOT install any file under /usr/lib/<triplet>/. A noarch package containing any of the categories above is invalid.

5.15.1 Exemptions #

Three arch-dependent payload categories are exempt from the triplet path, because each is addressed by something other than the host triplet:

CategoryPathAddressed by
Kernel content/usr/lib/modules/<release>/kernel release
Device firmware/usr/lib/firmware/device
Separated debug information/usr/lib/debug/the install path of the file it describes

A noarch package MUST NOT install under /usr/lib/modules/ or under /usr/lib/debug/: kernel content and debug information are both arch-dependent. /usr/lib/firmware/ carries no such restriction, firmware being opaque data rather than host-architecture content.

Debug files mirror the full install path of what they describe. The debug information for /usr/bin/foo is /usr/lib/debug/usr/bin/foo.debug; for /usr/lib/<triplet>/libfoo.so.1 it is /usr/lib/debug/usr/lib/<triplet>/libfoo.so.1.debug. Debug files MAY additionally be indexed by build ID under /usr/lib/debug/.build-id/.

The freedesktop os-release file is a fourth exemption of a different kind: it installs at exactly /usr/lib/os-release, a fixed external contract path the ecosystem hard-codes. Unlike debug information it is arch-independent, so a noarch package — the OS-identity package — MAY ship it. It is conventionally paired with a /usr/etc/os-release symlink, which the /etc merge projects to /etc/os-release.

5.15.2 Source #

The debugger source files that debug information references install under /usr/src/debug/, not under /usr/lib/. Source is architecture-independent, so /usr/src/debug/ carries neither a triplet rule nor the noarch restriction: it is a plain permitted destination that both arch-specific and noarch packages MAY use. The same applies to /usr/src/dist/, the home of corresponding-source packages.

Files retained inside /usr/src/dist/ are reconstruction inputs rather than installed library providers. Captured upstream or vendored inputs MAY include library-shaped test fixtures or import archives for other target platforms (such as Cargo's Windows crates), including in a noarch source package. Their .so or .a suffix does not make that reserved source-data tree a runtime library installation. The normal library-placement rule still applies outside this subtree; source data remains subject to path and symlink containment. Automatic ELF and pkg-config capability derivation MUST ignore /usr/src/ inputs: their libraries and .pc fixtures do not provide installed interfaces or create runtime dependencies of a source package.

Note

A corresponding-source package conventionally lays out /usr/src/dist/<name>-<version>/ with upstream/ — the pristine source artifact, byte-identical to the producer's pinned input — patches/ for the applied patch series, and recipe/ for the build-controlling recipe files including the source lock that makes the shipped upstream artifact verifiable. This is a producer convention, not a format rule.

5.15.3 Architecture-independent data #

/usr/share/ holds architecture-independent files shared across every architecture of a system: documentation, man pages, locales, configuration templates, and static data such as icons, images, and fonts.

Both noarch and arch-specific packages MAY install under /usr/share/. A file installed there by an arch-specific package MUST be byte-identical across every architecture build of the same upstream version.

5.16 Payload Entries

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

5.16.1 Permissions #

Tar entry permission bits in a package are distribution-format metadata only. They establish no access control on the installed file. Access control is the consumer's responsibility: on Peios, through a security descriptor applied at file-creation time (§5.20); on any other system extracting a package for inspection or migration, through that system's native mechanism applied after extraction.

Every payload entry's permission bits MUST be 0777. Any other value MUST cause the package to be rejected.

The setuid and setgid bits MUST NOT be set on a payload entry. Privilege escalation on Peios is mediated by the kernel's access-control subsystem, not by filesystem-level setuid; a setuid bit is meaningless to the access-check path and MUST NOT appear in installed content.

Note

The 0777 rule is honest signalling. A 0644 or 0755 mode would imply a permission contract the format does not enforce and the kernel does not consult. Fixing every entry to 0777, combined with the uid/gid and owner/group rules of §5.11, treats a payload as identity-free, permission-free transport bytes.

One consequence: extracting a package with a generic tar tool on a non-Peios host produces world-writable files. This is intended. Tooling that needs sensible host-native permissions on extracted files is responsible for applying them after extraction.

5.16.2 Empty directories #

A package MAY install an empty directory: a tar entry of type directory with no content. Empty directories establish paths a runtime will need, and are the only content permitted under /var/ (§5.14).

5.16.3 One package per path #

Two packages MUST NOT install a file at the same install path. A consumer MUST detect the collision and reject the second install.

A package MAY install content into a directory another package created; directory creation is idempotent. The rule applies to non-directory entries only.

The one exception is a claim link (§5.23), which belongs to the consumer rather than to any package and is materialised only at a path no installed package owns.

5.16.4 Detached signatures #

A payload entry whose path ends in .peios.sig is a detached signature: the 3310-byte binary-signature blob of PSPK chapter 3 for the entry whose path is the same with that suffix removed (its target), stored as ordinary payload bytes. The suffix is reserved; a package MUST NOT use it for any other purpose.

This is how a signature reaches a file that cannot carry one in an ELF section. §5.11 forbids extended attributes on tar entries, so the security.peios.sig placement PSPK defines cannot travel inside a package; the detached entry carries the same blob as payload instead, and the consumer derives the attribute from it.

A consumer MUST reject a package in which a detached signature:

  1. has no target — the path without the suffix is not a payload entry of the same package, or is a directory or symlink entry;
  2. is not exactly 3310 bytes, or its first byte is not 0x01; or
  3. has a target whose first four bytes are \x7fELF. An ELF file carries its signature in its .peios.sig section; giving it a detached signature as well would leave two carriers for one file, and PSPK's lookup order would silently ignore the second.

A consumer that installs the target MUST, as part of creating the file and before the file becomes visible at its install path, set the extended attribute security.peios.sig on it to exactly the detached entry's bytes, and MUST NOT create a file at the detached entry's own path. The detached entry is transport for the attribute, not content: it is not installed, is not recorded as a file the package owns, and takes no part in the one-package-per-path rule above. Removing or replacing the target removes or replaces the attribute with it.

The consumer verifies nothing about the signature beyond its shape. The blob is the signer's assertion and the kernel's to verify; a consumer that can install the package cannot generally hold the keys that would let it check the blob, and MUST NOT refuse a package because it cannot.

Note

A consumer extracting a package on a system where the attribute cannot be set — an unprivileged inspection, a filesystem without security.* support — has produced a root in which those files are unsigned, exactly as if a copy had dropped the attribute. That is the fail-closed outcome the kernel's verifier is designed for, but such a consumer SHOULD report it rather than proceed silently.

5.16.5 Forward compatibility #

The triplet path convention of §5.15 is designed so that a future multi-architecture system MAY install foreign-architecture packages alongside native ones without filesystem-level collisions.

In this version only one architecture's packages may be installed on a given system at a time (§5.8). The triplet convention applies regardless, so that a package conforming to this version stays forward-compatible with such an extension.

5.17 Symlinks

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Symlinks are first-class payload entries. The tar entry's linkname is the symlink target.

5.17.1 Target constraints #

A symlink target MUST be a relative path.

A symlink target MUST resolve, when joined with the symlink's parent directory, to a path that is either within the package's own payload tree or under one of the permitted top-level install destinations of §5.14. An absolute target is forbidden, as is a target whose resolution escapes those destinations entirely.

A symlink target is subject to the same path-validity constraints as a payload path (§5.13): valid UTF-8, no NUL bytes, no ASCII control characters, no backslashes, NFC normalisation, and the length limits.

A consumer MUST validate every symlink target against these constraints before extracting the entry. A package containing a non-conforming symlink target MUST be rejected.

Note

Format-level validation cannot distinguish a symlink whose resolved target is an installable path that no package actually installs. A target reached by .. traversal into a permitted destination passes validation and produces a dangling symlink; one that would overwrite an existing owned file is caught by the one-package-per-path rule (§5.16). Both are consumer-side outcomes, not format errors.

5.17.2 Cross-package targets #

A producer MAY emit a symlink whose target resolves into a different package's payload tree, provided the resolved path is under a permitted destination. The canonical case is the conventional library split, where a -dev package ships a developer link (libfoo.so) whose target (libfoo.so.1) lives in the corresponding runtime package.

A producer SHOULD declare the target's owning package as a dependency, so that the target is present at extraction time. The format does not record this relationship at the symlink level; it is captured at the package level through dependencies (§5.21).

Note

Prohibiting cross-package symlinks was considered — it would have forced restructured -dev splits with developer links living in runtime packages — and rejected. Forbidding them deviates from universal Linux convention with no corresponding security gain: the defence below operates on resolved-target validity, not on whether the resolution crosses a package boundary.

5.17.3 Integrity #

A symlink has no content body, and so is not hashed in the files manifest (§5.25). Its target is integrity-checked directly: the linkname stored in the tar header is what the consumer compares, and that header is inside the signed bytes (§5.28).

5.17.4 Security descriptors #

A symlink does not carry an independent security descriptor. Access to a symlink is governed by access to its target. A security descriptor override (§5.20) MUST NOT target a symlink entry.

Note

The classic symlink TOCTOU attack — install /usr/share/foo as a symlink to a system file, then have a later write at /usr/share/foo/bar traverse it — is prevented in two layers. The format forbids symlink targets resolving outside the managed tree, and extraction resolves every path component without following a symlink, so even an in-tree symlink cannot redirect a later write.

5.18 The Manifest

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

The manifest is the authoritative metadata for a package: its identity, its relationships, its side-effect requirements, and its build provenance. It is a JSON document at .peipkg/manifest.json.

5.18.1 Schema #

{
  "schema_version": 1,
  "name": "<string>",
  "version": "<string>",
  "architecture": "<string>",
  "description": "<string>",
  "license": "<string>",
  "license_class": "<string>",
  "homepage": "<string>",
  "default_root": "<root reference>",
  "special_system_package": <bool>,
  "alternate_upgrade": {<alternate_upgrade>},
  "dependencies": [<dependency>...],
  "optional_dependencies": [<dependency>...],
  "conflicts": [<dependency>...],
  "provides": [<provides>...],
  "replaces": [<replaces>...],
  "side_effects": [<string>...],
  "size_installed": <integer>,
  "sd_overrides": [<sd_override>...],
  "build": {<build>}
}

5.18.2 Required fields #

FieldTypeDescription
schema_versionintegerMUST be 1 in this version.
namestringPackage name conforming to §5.3.
versionstringVersion conforming to §5.5.
architecturestringArchitecture identifier conforming to §5.8.
dependenciesarrayRequired dependencies. MAY be empty; MUST be present.
conflictsarrayConflicting packages. MAY be empty; MUST be present.
size_installedintegerTotal size in bytes of the installed payload.
buildobjectBuild provenance.

A manifest missing any required field MUST be rejected.

The package format treats name as an opaque canonical identifier within the §5.3 grammar; it does not derive or authenticate namespace ownership. Peios catalogues assign reverse-DNS names whose leading segments identify the upstream namespace and whose final segment is the familiar package name, such as com.amd.amd-ucode. Peios-originated packages use org.peios. Dependency and package-operation inputs that identify a concrete package carry the complete canonical name; virtual capabilities retain their own names. Repository signatures and build provenance, rather than the spelling of name, establish who produced a particular package.

5.18.3 Optional fields #

FieldTypeDescriptionAbsent means
descriptionstringOne-line human-readable description.empty string
licensestringSPDX identifier or expression.empty string
license_classstringWhich of the closed set of licence classes (§5.a2) the package's terms fall into: unknown, free, firmware or proprietary.unknown
homepagestringURL of the upstream project.empty string
default_rootstringThe root a top-level install of this package lands in when the operator names none (§5.19).the operator's current root
special_system_packagebooleanDeclares the package exempt from the §5.14 layout rules at production time (§5.14).false
alternate_upgradeobjectDeclares that the package is meant to be installed and upgraded through a path outside the package manager (see "The alternate_upgrade object" below).none
optional_dependenciesarrayDependencies that enhance but are not required.empty array
providesarrayVirtual names this package satisfies.empty array
replacesarrayPackages this one supersedes.empty array
side_effectsarrayMaintenance operations to invoke (§5.24).empty array
sd_overridesarrayPer-entry security descriptor overrides (§5.20).empty array

A manifest carrying an unknown field MUST NOT be rejected; the unknown field MUST be ignored (§5.9).

5.18.4 The alternate_upgrade object #

{
  "message": "<string>"
}
FieldRequiredDescription
messageyesText shown to the operator in place of proceeding. Non-empty UTF-8, at most 1024 bytes; MAY contain newlines and MUST NOT contain other control characters.

A package that sets alternate_upgrade states that it is meant to be installed and upgraded by some means other than a routine package operation — an operating-system edition moved by a dedicated tool that also reconciles what the package manager deliberately does not touch, for example. The package manager grants nothing on the declaration's behalf. Its whole effect is on the consumer:

  1. A request that names the package — install or upgrade with the package as an argument — or any install or named-upgrade plan whose operations would install or upgrade the package as a dependency, MUST be refused: nothing is applied, and the consumer MUST report the package's name, its message verbatim, and this warning:

    Alternate upgrade paths may bypass normal peipkg protections; ensure you fully trust the authors of the package before running.

  2. An upgrade of every installed package MUST hold the package back at its installed version, report it as in rule 1, and proceed with the rest of the upgrade. Holding back is not a failure.

  3. A consumer MUST provide a deliberate, per-invocation override (--bypass-alternate-upgrade in the reference implementation) under which rules 1 and 2 do not apply. The override is the intended way for the out-of-band tool to drive the package manager; it is never a default and is never persisted.

Composing a root from nothing — an image builder populating an empty tree, rather than a consumer changing an installed system — is not an install or upgrade in the sense above, and an image builder MUST ignore the field.

Note

This is the one place the format lets a package say "not through peipkg", and it is deliberately the weakest possible form of that: a message, and a refusal. A package cannot name a command for the consumer to run, cannot run anything itself, and cannot make the consumer do more than stop. The message may tell the operator what to run; the operator, not the package, runs it. The warning is mandatory precisely because whatever the message names sits outside every protection the package manager provides.

5.18.5 The build object #

{
  "timestamp": "<RFC 3339 timestamp>",
  "farm_id": "<string>",
  "source_ref": "<string>"
}
FieldRequiredDescription
timestampyesRFC 3339 timestamp of the build. MUST be UTC, MUST end with Z, and MUST NOT carry sub-second precision.
farm_idyesIdentifier of the build farm that produced this package.
source_refyesReference to the build inputs, sufficient to reproduce the build.
source_packagenoName of the corresponding-source package produced from the same recipe and inputs (§5.15).
recipe_refnoVCS identity of the recipe tree the build ran from — for example git:<commit>, suffixed +dirty when the work tree held uncommitted changes.
buildernoIdentity and revision of the producing tool, for example pekit/<revision>.

A consumer MUST treat an absent optional field as the empty value.

timestamp is also the modification time of every tar entry (§5.11 rule 2). A producer MUST set both identically.

source_ref is producer-defined but SHOULD be a machine-resolvable reference. The conventional form is a version-control URL with an explicit ref:

git+https://git.peios.org/sources/nginx#refs/tags/v1.26.2-3

Note

The build object exists to make reproducibility verifiable: a third party in possession of the build inputs and the recorded timestamp can re-run the build and compare the output bytes. The format does not mandate that verification; it supplies the inputs for it.

5.18.6 Field constraints #

description, when present, MUST consist only of printable ASCII in the range 0x20–0x7E. ASCII control characters and non-ASCII bytes MUST NOT appear. It SHOULD be a single line under 80 characters; longer descriptions belong in upstream documentation.

Note

The byte-range rule is deliberately cruder than a Unicode-category test. description is displayed to an operator deciding whether to install, so it is both a terminal-escape-injection surface and a homoglyph surface. A hard ASCII whitelist closes both without anyone having to reason about which Unicode categories are safe to render.

license, when present, SHOULD be a valid SPDX expression. A producer MAY use another form; this specification does not validate license strings. A LicenseRef- term names a licence with no SPDX identifier; a producer using one SHOULD ship the licence text under /usr/share/licenses/<name>/ in the same package.

license_class, when present, MUST be one of unknown, free, firmware, or proprietary; any other value MUST cause the package to be rejected. Absent means unknown, and a consumer MUST treat the two identically. A producer SHOULD declare a class other than unknown whenever license contains a LicenseRef- term, since that is precisely the case in which the expression alone cannot say.

Note

A licence expression cannot be read by a machine for what an operator actually wants to know — whether the package is free software, a redistributable device firmware blob, or proprietary code — and the LicenseRef- escape hatch makes every non-SPDX licence opaque. The class carries that one fact so a consumer can answer "what non-free is installed" or apply an image policy without parsing licences. firmware is a class of its own rather than a kind of proprietary because it is a different bargain: no source, executes on a device rather than the CPU, and needed to use hardware the user owns — the distinction Debian's non-free-firmware and Fedora's firmware exception both draw. unknown exists so every package that predates the field stays valid and honest: nothing has been asserted.

homepage, when present, MUST be a syntactically valid URL per RFC 3986 and MUST use the https or http scheme. Any other scheme MUST cause the package to be rejected.

size_installed MUST be a non-negative integer, and MUST equal the sum of the size fields of every entry in the files manifest (§5.25). A consumer MUST verify that equality and MUST reject a package where it does not hold.

Note

size_installed is not merely advisory. It is the input to the decompression bound of §5.27, so a package whose declared size is smaller than what it actually unpacks to is a resource-exhaustion attempt, and one that is larger inflates the bound. Tying it to a quantity a consumer can independently compute is what makes it trustworthy.

5.18.7 Authoritative status #

The manifest is authoritative for a package's metadata. Where it disagrees with any other source — the repository index, the filename, secondary documentation — the manifest MUST be treated as correct, and the disagreement MUST be reported (§5.32).

5.18.8 Encoding #

The manifest MUST be UTF-8 encoded JSON and MUST end with a single newline.

A producer that intends its packages to be byte-reproducible MUST serialise the manifest canonically: compact, with no insignificant whitespace, with HTML-escaping of <, >, and & disabled, with fields in the declaration order of the schema above, and with every optional field either always emitted or never emitted for a given producer.

Note

The manifest's bytes are inside the archive that gets hashed and signed, so two semantically identical manifests with different whitespace produce different packages. Pinning the serialisation is what makes independent reproduction possible at all; leaving it to the producer's discretion means only that producer can ever reproduce its own output.

5.19 Installation Roots

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

An installation root is a self-contained filesystem tree into which packages are installed. The default root is the system root; a system MAY define additional named roots — an initramfs image built and maintained alongside the main system is the motivating case.

How roots are registered, and how a name resolves to a filesystem location, is consumer mechanics and is not part of the package format.

5.19.1 Root references #

A root reference is the string form by which a manifest names a root. Within a manifest a root reference MUST be a named reference: one or more segments separated by ., where each segment matches [a-z0-9][a-z0-9_-]*. Nesting is expressed by further segments, so initramfs.subroot names the root subroot registered within the root initramfs.

A root reference in a manifest MUST NOT be an absolute or relative filesystem path. A package names roots and never dictates a filesystem location: placement is the installing system's prerogative.

A manifest whose root reference is not syntactically valid is invalid and MUST cause the package to be rejected. Whether the named root exists is a consumer-side resolution concern, not a format-validity one.

5.19.2 default_root #

The manifest's default_root field (§5.18) governs only the placement of a top-level install of the package — an operator request naming this package directly, with no explicit root.

It has no effect when the package is pulled in as a dependency; dependency placement is governed by the depending package and by the dependency's own root field (§5.21). An explicit operator-supplied root always overrides default_root.

5.19.3 Satisfaction is per-root #

The identity of a satisfier is the pair (name, root). The same package name installed in two different roots is two independent satisfactions, possibly at different versions, and a dependency is satisfied only by an installation in the named — or defaulted — root. A constraint or architecture qualifier is evaluated against that installation.

Note

Cross-root dependencies let a root be composed through the dependency graph. An initramfs package may depend on ordinary packages — a shell, core utilities — and have them placed into the initramfs root, either implicitly by living in that root itself or explicitly through the dependency's root field. The depended-on package declares no root affinity of its own; where it lands is the depender's and the operator's choice. This generalises the fixed build-host/target split other systems draw to an open set of named roots.

5.20 Security Descriptor Overrides

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every installed file and directory carries a security descriptor. A consumer applies it at file-creation time, through the kernel's file-creation interface — never through a tar attribute, which §5.11 rule 5 forbids outright.

5.20.1 The default is inheritance #

When a payload entry has no override, a consumer MUST create the entry without supplying an explicit security descriptor, so that the kernel computes one by inheritance from the parent directory's inheritable entries at creation time.

Inheritance is the default for the overwhelming majority of installed entries, and most packages declare no overrides at all. An override is appropriate when a file needs more restrictive access than its parent would give it, when it needs explicit access for a principal absent from the parent's inheritable entries, or when a directory needs to begin a new inheritance scope.

5.20.2 Declaring an override #

An entry in the manifest's sd_overrides array has the form:

{
  "path": "<payload-relative path>",
  "sd": "<base64-encoded security descriptor>"
}
FieldDescription
pathPayload-relative path, matching a tar entry exactly.
sdBase64-encoded binary self-relative security descriptor, per RFC 4648 §4 without padding.

The sd_overrides array MUST be sorted lexicographically by path, and MUST NOT contain two entries with the same path.

path MUST refer to a regular-file entry or a directory entry. An override MUST NOT target a symlink entry, which carries no independent descriptor (§5.17).

An override referring to a non-existent payload entry, or to a symlink entry, is invalid and MUST cause the package to be rejected.

sd MUST decode to a syntactically valid binary self-relative security descriptor. One whose decoded bytes do not parse is invalid and MUST cause the package to be rejected.

A consumer MUST perform all three of those checks — entry existence, entry type, and descriptor parseability — before installing anything from the package. Deferring them to the moment the descriptor is applied turns a malformed package into a partially completed install.

5.20.3 The consumer's policy obligation #

The kernel validates that a declared descriptor is well-formed. It does not validate that the producer of a package had any authority to declare that descriptor on behalf of the principals it grants access to. A package can therefore declare a descriptor granting access to any principal the system knows about. The format treats the bytes as opaque; whether a given package may declare a given descriptor is policy, and that policy is the consumer's to enforce.

A consumer MUST enforce a per-repository override policy:

  1. Before applying any override, the consumer MUST surface it to the operator in human-readable form, including the payload path, the principals and rights granted, and a diff against what inheritance would have produced.
  2. For a package from the system's official repository, overrides MAY be applied without per-operation confirmation, but the operator-visible install report MUST list every override applied.
  3. For a package from any other repository, the consumer MUST require explicit operator confirmation before applying an override that grants rights to a principal outside a configured allowlist. The default allowlist contains the well-known system principals, plus any principal the operator has added to that repository's allowlist. It MUST NOT contain any principal derived from the package itself — from its manifest fields, its build metadata, or its payload. A package cannot elect its own principals into the allowlist.
  4. A package whose overrides the policy rejects MUST be refused. A consumer MUST NOT silently drop the overrides and proceed with inheritance defaults.

5.20.4 Inherited descriptors are covered too #

The policy applies both to explicitly declared descriptors and to descriptors that result from inheritance from a directory whose own descriptor was declared by any package's overrides.

Specifically: when installing a file under a directory whose descriptor was overridden by any package — from any repository — the resulting inherited descriptor MUST pass the policy as if the installing package had declared it.

Note

Without this, package A declares an override on a directory that grants A's author rights, and package B's files installed under that directory silently inherit it with the operator never prompted. The check fires on what descriptor ends up on the file, regardless of how it got there — which also closes the case where a carefully-mimicked "looks like the default" override would have evaded a test for non-default descriptors.

5.20.5 Failure #

If file creation fails because the kernel rejects the descriptor — most often because it references a principal the system does not know — the install MUST be treated as failed and any partial state rolled back.

A package MUST NOT be installed into a parent directory whose descriptor denies the caller the access required to create the entry. A consumer detects this at install time and treats it as an install failure.

5.21 Relationships

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package expresses its relationships to other packages in five manifest fields:

  • dependencies — packages that must be installed for this one to function
  • optional_dependencies — packages that enhance functionality but are not required
  • conflicts — packages that must not be installed alongside this one
  • provides — virtual names this package satisfies on behalf of dependencies declared elsewhere
  • replaces — packages this one supersedes

5.21.1 Dependency entries #

An entry in dependencies or optional_dependencies:

{
  "name": "<package or virtual name>",
  "constraint": "<version constraint>",
  "arch": "<arch qualifier>",
  "root": "<root reference>",
  "claims": { "<slot>": { "path": "<absolute path>" } }
}
FieldRequiredDescription
nameyesThe depended-on package name (§5.3) or virtual name (§5.4).
constraintnoA version constraint per §5.7. Absent means any version satisfies.
archnoAn architecture qualifier. Default any.
rootnoA root reference (§5.19) naming the root this dependency is placed and satisfied in. Absent means the same root as the depending package.
claimsnoClaim paths this dependency expects a holder to materialise (§5.23).

root, when present, MUST be a syntactically valid named root reference — never a filesystem path. An entry whose root is not one is invalid.

5.21.2 Conflict entries #

An entry in conflicts has the same shape as a dependency entry, minus root and claims, and expresses incompatibility rather than requirement: a package MUST NOT be installed simultaneously with any package matching the entry. A conflict whose constraint is absent expresses incompatibility with any version of the named package.

5.21.3 The architecture qualifier #

arch restricts the qualified package's architecture. In this version the only valid value is any, which is the default. Any other value MUST be rejected.

any means: the qualified package's architecture MUST equal the depending package's effective architecture, or be noarch.

A depending package's effective architecture is its own architecture when arch-specific, and the system's primary architecture (§5.8) when the depending package is noarch. A noarch label describes an architecture-independent payload, not an architecture-independent resolution context: a noarch package's dependencies on arch-specific packages — a script on its interpreter, a meta-package on native tools — resolve against the concrete system being assembled, exactly as a native package's do.

Note

A future version may permit explicit architecture identifiers here, to support multi-architecture systems. Reserving the field now is what makes such an extension parse compatibly.

5.21.4 Provides entries #

{
  "name": "<virtual name>",
  "version": "<version string>",
  "claims": { "<slot>": { "target": "<absolute path>",
                          "path": "<absolute path>" } }
}
FieldRequiredDescription
nameyesThe virtual name provided, conforming to §5.4.
versionnoThe version of the capability provided. Parsed revision-relaxed (§5.7), because a provides version is a capability level rather than a packaging iteration. Absent means any version of the name is provided.
claimsnoFilesystem targets this package materialises when it holds the named role (§5.23).

A virtual name that collides with a real package name MAY be provided; both are then valid satisfiers of a dependency on that name.

provides.version SHOULD reflect the providing package's actual functional compatibility level. A provides.version greater than the providing package's own version MUST generate an operator warning at install time, because an inflated provides-version defeats constraint-based resolution.

Note

The attack the warning exists for is concrete: a package at version 1.0-1 declaring provides: [{name: libfoo, version: "5.0"}] satisfies a dependency on libfoo >= 4.0 and gets installed in place of a real libfoo — most easily when the real one is absent from the candidate set, which is precisely the shadowing case.

The provides relation does not flow transitively: providing smtp-server does not provide whatever smtp-server itself provides.

5.21.5 Replaces entries #

{
  "name": "<package name>",
  "constraint": "<version constraint>"
}

name is required and MUST conform to the package-name grammar (§5.3). constraint is optional; absent means this package replaces any version of the named one.

A replaces entry expresses supersession. During upgrade the replaced package is removed and this one installed in its place: files owned by the replaced package that no longer exist in this one are removed, and files existing in both are updated.

A replaces entry does not imply a conflict. A package MAY both replace and conflict with the same target, but a replaces entry is typically sufficient on its own.

Note

Replaces is the rename mechanism. When nginx-core becomes nginx, the new nginx declares { "name": "nginx-core" } in replaces, and existing systems transition on their next upgrade.

5.21.6 Field constraints #

Each of the five fields is an array of objects matching the appropriate schema. dependencies and conflicts MUST be present, and MAY be empty. The other three MAY be omitted, which is equivalent to an empty array.

Within a single field, entries MUST be sorted lexicographically by name, and two entries MUST NOT carry identical name values. A package with several constraints on one target MUST combine them into that entry's single constraint string.

5.21.7 What satisfies a dependency #

A dependency is satisfied by a candidate package when all of the following hold:

  1. The candidate's name equals the dependency's name, or the candidate has a provides entry whose name equals it.
  2. If the dependency carries a constraint, the version satisfies it — the candidate's own version when matched by name, and the matching provides entry's version when matched through provides.
  3. The candidate's architecture satisfies the arch qualifier.
  4. The candidate is installed, or is being installed, in the dependency's root (§5.19).

A conflict is triggered by a candidate when the same conditions hold with respect to a conflicts entry.

A claims field has no effect on satisfaction. A dependency on a role is satisfied by any installed eligible provider regardless of which one currently holds the role; claims govern which installed file owns a contended filesystem name, nothing more (§5.23).

5.22 Derived Capabilities

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Some capabilities are derived mechanically from a package's built contents rather than declared by hand. So that a producer and a consumer agree on the name whichever way it arrived, the conventions below are normative for the capability name.

CapabilityVirtual nameVersion
Shared libraryThe ELF soname, verbatim — libssl.so.3.None by default.
ELF symbol-version nodeelfver(<soname>:<token>), for example elfver(libstdc++.so.6:GLIBCXX_3.4.30).None: the whole name is an exact capability.
pkg-config modulepkgconfig(<module>), where <module> is the .pc file's base name — pkgconfig(glib-2.0).The .pc file's Version: field, matched as an ordered constraint per §5.7.

5.22.1 Shared libraries #

A shared-library dependency is the soname listed in a binary's DT_NEEDED; the corresponding provide is the soname in the providing library's DT_SONAME.

The soname's ABI-version field is part of the name and is matched by exact equality: libssl.so.3 is never satisfied by libssl.so.4. A version MAY be carried on a soname provide when the library's symbol versions are commensurable with the providing package's own version, as they are for a C library shipping versioned symbols.

5.22.2 ELF symbol-version nodes #

A strong GNU ELF version need is represented by elfver(<soname>:<token>); the matching provide comes from that SONAME's non-base version definition. Both components preserve case and exact bytes. The colon separates components; it is not permitted within either component. Tokens use ASCII letters, digits, underscores, dots, plus and minus characters; the complete capability obeys the normal capability-name length bound. Weak version needs do not impose a mandatory dependency. A producer must reject unrepresentable metadata rather than omit a selected version requirement.

These capabilities supplement SONAME identity and are independent of the provider package's version. All required nodes must be present; defining a numerically greater node alone is insufficient. A token from another SONAME does not satisfy the dependency. Producer policy selects the libraries to which this derivation applies; Peios selects libstdc++ to cover GLIBCXX and CXXABI.

5.22.3 pkg-config modules #

A pkg-config dependency is a module named in a .pc file's Requires: or Requires.private:; the corresponding provide is the .pc file itself.

5.22.4 Derivation is a producer concern #

Whether a producer derives these automatically is its own business. This section fixes only the names, so that a hand-written entry and a derived entry for the same capability are byte-identical.

Note

A producer that derives from ELF metadata will encounter cases this section does not name: a symlink that carries no DT_SONAME of its own, a shared-library-shaped file with no soname at all, symbol version tokens from DT_VERNEED that could be turned into a version constraint. Any additional constraint a producer synthesises is a constraint like any other and is evaluated by §5.7; what this section forbids is inventing a different name for a capability that has one.

5.23 Claim Declarations

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

provides (§5.21) lets several installed packages satisfy one virtual name. A claim extends that to the filesystem: it lets several installed packages contend for a single shared filesystem name, with exactly one owning it at a time. The canonical case is a role daemon — two registry sources may both be installed, but only one may own /usr/sbin/registryd.

This section specifies what a package declares. Which provider holds a role, and when the consumer re-evaluates that, is consumer mechanics.

5.23.1 Vocabulary #

  • Role — a virtual name (§5.4) that one or more packages contend to own. A role is identified by the name of a provides or dependency entry carrying a claims field.
  • Slot — a named channel within a role. Each slot materialises one filesystem name. A role has one or more slots.
  • Claim path — the absolute path a slot materialises at. A slot MAY have more than one.
  • Target — the file a claim path points at while a given provider holds the slot. The target is a payload file of the holding package.
  • Holder — the single installed package that currently owns a role. A role with no holder is unheld.

Note

The two halves of a claim are declared by two different parties. The consumer — whatever hard-codes /usr/sbin/registryd and expects to find a registry daemon there — declares the path. The provider declares the target, the file of its own that should answer it. The consumer joins the two by (role, slot). Splitting the declaration this way puts the path where the dependency on it lives and the target where the implementation lives.

5.23.2 The claims field #

A claim is declared by adding a claims field to a dependency entry, an optional-dependency entry, or a provides entry. It maps a slot name to a slot descriptor:

"claims": {
  "<slot name>": { "path": "<absolute path>",
                   "target": "<absolute path>" }
}

A slot name MUST conform to the package-name grammar (§5.3).

Which of the two descriptor fields is permitted depends on where the claims field appears:

  • On a dependency or optional-dependency entry — the consumer side — each slot descriptor MUST contain path and MUST NOT contain target. A consumer declares only where it expects the name; it supplies no implementation.
  • On a provides entry — the provider side — each slot descriptor MUST contain target and MAY contain path. A provider declares the file that answers the slot, and MAY additionally declare a default claim path.

A claims field MUST NOT appear on a conflicts or replaces entry.

Slot keys within a claims object are an unordered JSON object and carry no ordering requirement. The enclosing arrays remain sorted and unique by name (§5.21).

Example — one package consumes the role, another provides it:

// the consumer's manifest
"dependencies": [
  { "name": "registryd",
    "claims": { "binary": { "path": "/usr/sbin/registryd" } } }
]

// the provider's manifest
"provides": [
  { "name": "registryd",
    "claims": { "binary": { "target": "/usr/sbin/loregd" } } }
]

5.23.3 Where a target may point #

A target MUST name a path the declaring package itself installs as a payload entry, and MUST therefore lie within the permitted install destinations of §5.14. A target that does not correspond to one of the declaring package's own payload paths is invalid and MUST cause the package to be rejected.

A consumer MUST verify this itself, against the payload it actually received. Producer-side validation says nothing about a package built elsewhere.

5.23.4 Where a claim path may lie #

A claim path is not a payload entry — it is the location of a consumer-managed link — and is governed by its own rule. It MUST satisfy the payload path-syntax and safety constraints of §5.13, and it MUST lie in one of:

  • the permitted install destinations of §5.14;
  • under /run/; or
  • the well-known root-level name /init.

Any other location MUST cause the package to be rejected.

Note

A link location and a target are constrained differently because they are different kinds of object. A target is a real file the provider ships, so it obeys the ordinary payload rules. A claim path is a name the consumer owns and points at that file; permitting /run/ for it — without permitting packages to ship payload there — lets a role expose a runtime socket while keeping /run off-limits to package payloads. /init is admitted for the same reason in the other direction: it is a well-known file a provider must own directly, and it is not a payload destination.

/lcl/policy MUST NOT be reachable as a claim path under any circumstance, by the same rule and for the same reason as §5.14.

5.23.5 Eligibility #

A package is an eligible provider of a role when it has a provides entry whose name is the role and whose claims field declares a target for at least one of the role's slots. Only an eligible provider may hold a role.

A package that depends on a role and declares a claim path for it, but does not provide the role, is a consumer only: it contributes claim paths and can never hold.

5.23.6 What a consumer guarantees #

The materialised links for a role MUST at all times equal the cross-product of the role's computed claim paths with the holder's targets, where the computed claim path set for a slot is the union of:

  • every path declared for that slot by an installed consumer, and
  • the path declared for that slot by the holder's own provides entry, if present.

A consumer MUST re-evaluate that set within any transaction that changes its inputs — a change of holder, or the installation or removal of any package declaring a claim path or a target for the role. A claim path declared for an already-held role MUST be materialised retroactively against the current holder; the holder is not re-decided.

A role MAY be held with no materialised links at all, when its computed path set is empty. Holder state is therefore recorded independently of whether any link exists.

A claim link is owned by the consumer, not by any package. It MUST NOT appear in any package's payload and MUST NOT be recorded as a package-owned path. This is what lets two eligible providers coexist: neither ships the contended path, so the one-package-per-path rule (§5.16) is never engaged by the providers themselves.

A claim path MUST NOT collide with a path owned by any installed package, evaluated against the state the containing transaction will produce rather than the state it started from. On collision, materialisation MUST fail and the transaction MUST be rolled back.

A holder swap MUST repoint every one of the role's links within a single transaction, and each repoint MUST be atomic, so that no consumer of a claim path ever observes the path absent.

Note

Atomic repoint matters because a claim path is typically on the critical path of a running system: a contended daemon binary may be executed at any moment. Tearing the old link down and building the new one as two steps exposes a window in which the name does not exist.

5.23.7 What claims are not #

  • Not a general symlink mechanism. A package needing a fixed symlink among its own files ships a payload symlink entry (§5.17). Claims exist for names contended by several packages.
  • Not a service-registration or activation mechanism. A materialised claim link is a symlink and nothing more.
  • Not an input to dependency resolution (§5.21).
  • Not a way to escape the one-package-per-path rule for ordinary payload files. Only consumer-owned claim links are exempt, and only at paths no package owns.

5.24 Side-Effect Declarations

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Some standard maintenance operations must run after files are installed or removed for a system to function: rebuilding the kernel module dependency cache when modules change, rebuilding the man page index when man pages are added.

These are not install scripts. The format does not permit a package to specify its own install script. A package instead declares which of a closed, enumerated set of maintenance operations it requires, and the consumer invokes them.

5.24.1 Schema #

side_effects is an array of strings:

"side_effects": ["depmod", "man-db"]

Each string MUST be drawn from the set below. An unknown value is invalid and MUST cause the package to be rejected. The array MUST NOT contain duplicates. It MAY be empty, or omitted entirely, for a package requiring no maintenance operation.

5.24.2 The recognised set #

5.24.2.1 depmod #

Rebuilds the kernel module dependency cache for a kernel release.

A package MUST declare depmod if its payload contains kernel module files (.ko, .ko.*) under /usr/lib/modules/. A package MUST NOT declare it if its payload contains no kernel modules.

The consumer MUST invoke it once per affected kernel release, naming that release. A package shipping modules for two releases causes two invocations.

Note

Naming the release matters more than it appears to. An invocation with no release operand acts on the running kernel, which during a kernel update or an image build is precisely not the kernel whose modules were installed — leaving that release's dependency cache unbuilt and its modules unloadable.

5.24.2.2 man-db #

Rebuilds the man page index, so that lookups by keyword are fast.

A package SHOULD declare man-db if its payload contains man pages under /usr/share/man/.

Note

man-db is SHOULD rather than MUST because man page lookup degrades gracefully without it — queries fall back to a filesystem scan. A package omitting it is suboptimal, not broken.

5.24.3 Semantics #

A side effect MUST be idempotent: running it several times in succession MUST leave the system in the same state as running it once. The recognised set has this property by construction.

A side effect MUST be safe to invoke non-interactively.

A consumer MUST invoke each declared side effect once per transaction, after every file operation in that transaction is in place and after the transaction has committed. Side effects MUST be deduplicated across the packages in a transaction: several packages each declaring man-db cause one invocation, not several.

A consumer MUST also invoke a side effect when a transaction removes files whose absence affects that effect's target — removing a kernel module requires depmod, removing a man page requires man-db — whether or not any package in the transaction declared it.

5.24.4 Why there is no shared-library cache #

Other systems carry an ldconfig side effect to rebuild /etc/ld.so.cache. Peios has no such cache and no such side effect, and this is a property of the layout rather than an omission.

A cache exists to do two things: make lookup fast when the loader must search many directories, and let it find libraries in directories it would not otherwise search. Peios has neither problem. The C library is configured with its library directory, its system library directory and its runtime-loader directory all set to /usr/lib/<triplet>, and the loader carries that path compiled in as its default. There is exactly one shared-library directory, and it is the one the loader already searches.

So the rule that replaces the declaration is a layout rule, and it is normative: a package shipping a shared library MUST install it into /usr/lib/<triplet>. A library installed anywhere else will not be found, and no maintenance operation exists to make it findable.

Reintroducing a cache would mean reintroducing everything a cache brings with it — a file to keep coherent with the filesystem, a tool in the base to regenerate it, and a failure mode where the two disagree. That trade is only worth making if Peios ever needs more than one library directory.

5.24.5 Ordering #

Side effects are invoked in an implementation-defined order. The recognised set is chosen so that order between distinct effects is not significant, and a consumer MAY invoke them concurrently.

5.24.6 Invocation hardening #

A consumer MUST invoke a side-effect tool with:

  • a fixed absolute path to the tool. The set is closed, so the consumer knows each tool's location; it MUST NOT search a path variable.
  • a cleared environment containing only well-defined variables. Environment inherited from the invoking context MUST NOT be passed through.
  • standard input closed.

Note

If a consumer located the tool by searching a path variable, a package could shadow the intended tool through an inherited search path. Because the set is closed the consumer needs no configurable allowlist — it invokes a known path. A cleared environment closes the matching injection route.

A consumer MUST invoke the tool against the installation root the transaction acted on, not against the root the consumer itself is running from.

5.24.7 Failure #

Side effects run after the transaction commits, so a side-effect failure does not — and cannot — roll the transaction back. A consumer MUST report the failure to the operator; the transaction stands.

Because side effects are idempotent, a failed one is self-correcting: re-invoking it, explicitly or as part of the next transaction that declares it, reaches the correct state. A consumer SHOULD make re-invocation straightforward.

Note

Rolling a committed transaction back because a cache rebuild exited non-zero would be disproportionate: the packages installed correctly and only a cache lagged, recoverably.

5.24.8 Extension #

A future version MAY recognise further identifiers — likely candidates include update-mime-database, update-desktop-database, and udev-reload, all excluded here as irrelevant to the scope Peios is built for. A conforming implementation of this version MUST reject a manifest declaring any identifier outside the set above.

A future version introducing a new identifier MUST state whether it is order-independent with respect to the existing set. An order-dependent side effect, if one is ever added, MUST be specified with a normative ordering relative to every other recognised effect.

5.24.9 What side effects are not #

  • Not a general install-script mechanism. The closed enumeration is what prevents arbitrary code execution at install time.
  • Not a way to register a service with the init system. Service integration belongs to the higher-level artifacts that compose packages.
  • Not a way to seed registry state.
  • Not a way to apply security descriptors, which are applied at file-creation time (§5.20).

A package whose required behaviour cannot be expressed through the manifest is incomplete and cannot be installed through the package format alone. That behaviour MUST be supplied by the higher-level artifact that composes the package.

5.25 The Files Manifest

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package's integrity is verified at two levels. At the package level, the whole file has a hash and a signature proving it has not been altered since signing. At the per-file level, each payload file has an individual hash proving its content has not been altered between archive creation and installation.

The per-file level lives in the files manifest at .peipkg/files.json.

5.25.1 Schema #

{
  "schema_version": 1,
  "algorithm": "sha256",
  "entries": [
    { "path": "<string>", "size": <integer>, "hash": "<hex string>" }
  ]
}
FieldDescription
schema_versionMUST be 1 in this version.
algorithmMUST be sha256 in this version.
entriesOne entry per regular-file payload entry.
Entry fieldDescription
pathPayload-relative path, identical to the corresponding tar entry path.
sizeSize in bytes of the file's content.
hashLowercase hexadecimal hash of the file's content under the declared algorithm.

The entries array MUST be sorted lexicographically by path and MUST NOT contain duplicates.

5.25.2 Coverage #

The files manifest MUST contain exactly one entry per regular-file payload entry, and MUST NOT contain an entry for a metadata entry under .peipkg/, a directory entry, or a symlink entry.

A regular-file payload entry with no corresponding files-manifest entry is invalid. A files-manifest entry with no corresponding tar entry is invalid. Either MUST cause the package to be rejected on parse.

Note

The correspondence is checked in both directions because each direction catches a different failure. A file with no entry is an unverifiable file smuggled into the payload; an entry with no file is a hash for something that was never shipped, which makes the manifest's own count untrustworthy.

Symlinks are integrity-checked through the tar entry's linkname directly, and directories have no content. The files manifest covers only what is verifiable by content hash.

5.25.3 The package hash #

The package hash is the hash of the entire .peipkg file in its compressed on-wire form, computed with the algorithm declared in the repository index (§5.33). The required algorithm in this version is SHA-256.

It is recorded in the repository index, to verify that a downloaded file matches what the repository advertises, and in the signature payload (§5.28), to bind a signature to that exact file.

The package hash is not recorded inside the package: a package cannot contain its own hash.

5.25.4 Algorithm agility #

This version supports SHA-256 only. The algorithm field here and the hash identifier in the index reserve syntactic space for future algorithms. A conforming implementation of this version MUST reject any algorithm value other than sha256.

Note

BLAKE3 is a likely future addition for performance on large packages. The reservation means such a migration can be additive: producers continue emitting SHA-256 for compatibility with this version while a future version permits BLAKE3 as well.

5.25.5 Two levels, two threats #

The two levels defend against different things, and a consumer MUST verify both.

The package hash plus the signature defends against substitution of the package as a whole. The files manifest defends against corruption or tampering during extraction, after the signature has been verified.

Verifying only the signature leaves extraction errors and on-disk corruption undetectable. Verifying only the per-file hashes leaves the files manifest itself untrusted.

5.26 Verifying a Package

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A consumer MUST perform the following steps, in this order, before installing anything from a package:

  1. Compute the SHA-256 of the downloaded .peipkg file.
  2. Compare it against the hash recorded in the repository index (§5.33). If they differ, the package is corrupted or substituted; abort.
  3. Verify the inline signature (§5.30). If verification fails, the package's authenticity is unproven; abort.
  4. Decompress and parse the tar archive, enforcing the layout rules of §5.12, the determinism rules of §5.11 and the detached-signature rules of §5.16.
  5. Read .peipkg/manifest.json and verify it against §5.18.
  6. Read .peipkg/files.json and verify it against §5.25, including the two-way coverage check and the size_installed equality of §5.18.
  7. For each payload entry, compute its content hash and compare it against the files manifest. If any file's hash does not match, abort.
  8. Compare the manifest against the index entry that led here (§5.32). If any field disagrees, abort.

A consumer MUST NOT install any payload before all eight steps complete successfully. Partial installation after a verification failure leaves the system indeterminate and is forbidden.

5.26.1 Ordering is logical, not temporal #

A consumer MAY compute the hashes for steps 1, 3, and 7 in a single streaming pass: feeding the compressed bytes simultaneously through a hasher and a decompressor, piping the decompressed bytes through a second hasher up to the signature entry, and hashing each file's content as the tar walk reaches it.

What the ordering requires is that no payload is committed to its final install path, and no decompressed byte is made visible outside the consumer's own private state, until every step has completed.

5.26.2 Nothing observable before step 3 #

A consumer MUST NOT make any decompressed payload byte visible to another process — including through a staging directory reachable from outside the consumer's own process tree — before signature verification has succeeded.

Streaming decompression and hashing are permitted. Observable filesystem effects are not.

5.26.3 Verifying the whole transaction first #

When a consumer installs several packages together, it MUST complete steps 1 through 8 for every package before extracting any package's payload, and it MUST do so across every installation root the operation touches.

Note

This closes a class of multi-package attack: package A is verified, extracted, and its contents then influence the verification or extraction of package B — A installs a tool B's extraction invokes, or A creates a directory whose descriptor decides where B's files land. Verifying everything first means extraction operates on a known-good set of payloads. The rule binds across roots as well as within one, because a package extracted into one root is just as present on the filesystem as one extracted into another.

5.26.4 Committing a payload #

A consumer MUST resolve every path component of an install location relative to a verified parent-directory file descriptor, without traversing any symbolic link — including one the consumer itself created earlier in the same operation. A resolution that would traverse a symlink MUST abort the operation.

A pre-existing symlink at an install path MUST be removed atomically before the write, and MUST NOT be followed.

A well-formed package never contains a payload entry whose ancestor component is a symlink. A resolution failure therefore indicates a malformed or hostile package, or hostile filesystem state.

On Linux this is achieved with openat2(..., RESOLVE_NO_SYMLINKS) anchored at the relevant permitted top-level destination (§5.14), or with equivalent semantics using O_NOFOLLOW on every component against a carried directory descriptor. A consumer SHOULD additionally apply RESOLVE_BENEATH, RESOLVE_NO_XDEV, and RESOLVE_NO_MAGICLINKS as defence in depth, and SHOULD commit a staged file with renameat2 against the same pinned parent descriptor rather than a re-walked path string.

Note

The format's symlink-target rules (§5.17) are the first layer of the defence and this is the second, and neither is sufficient alone. The format forbids a symlink whose target resolves outside the managed tree; component-wise resolution ensures that even an in-tree symlink — or one already on the filesystem before this package arrived — cannot redirect a write. Without the second layer, a package shipping a symlink in one transaction and a file under that symlink in the next silently writes outside where it claims to, with no race required.

5.27 Decompression Bounds

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A consumer MUST bound package decompression, to prevent resource-exhaustion attacks by packages with extreme compression ratios.

Two bounds apply, and both MUST be enforced.

5.27.1 The index-declared bound #

The repository index entry's size_compressed and size_installed fields (§5.33) bound the legitimate sizes of the compressed and uncompressed forms. During streaming decompression a consumer MUST verify that:

  • the cumulative compressed bytes consumed do not exceed size_compressed by more than the lesser of 1% or 16 MiB; and
  • the cumulative decompressed bytes produced do not exceed size_installed plus a fixed overhead allowance of 320 MiB.

Both figures MUST be taken from the index entry, not from the package's own manifest. The manifest lives inside the compressed stream and is therefore under the control of whoever produced the bytes being bounded.

Note

This is the whole point of the bound, and it is easy to get backwards. A consumer that reads size_installed out of the manifest it is in the middle of decompressing has derived its cap from the input it is defending against: a hostile package simply declares a large enough figure. The index entry is signed by the repository, independently of the package, which is what makes it usable as a bound.

The 320 MiB decompressed allowance bounds the structural overhead a conforming package may legitimately carry above its installed payload: tar headers and block padding for up to the §5.A limit of 100,000 entries, plus the metadata files at their maximum sizes. A typical package's overhead is a tiny fraction of it; the allowance is sized so that a consumer never rejects a package conforming to §5.A.

5.27.2 The absolute cap #

Independently of any declared size, a consumer MUST abort decompression when the cumulative decompressed output exceeds an absolute cap. The default cap is 4 GiB. A consumer MAY raise it through operator configuration but MUST NOT raise it silently.

5.27.3 Checked continuously #

Both bounds MUST be checked on every chunk of output, not at end-of-stream.

Note

Constructed Zstandard payloads can achieve compression ratios beyond 10,000:1, so a 10 MB package can decompress to terabytes. A check deferred to end-of-stream never runs.

5.27.4 On exceeding a bound #

Exceeding either bound MUST cause the package to be rejected with no further processing and nothing committed to disk.

5.27.5 Cross-checking the declared size #

The manifest's size_installed and the index entry's size_installed MUST be equal, and a consumer MUST verify that equality (§5.32). Together with the files-manifest sum required by §5.18, this makes the figure a quantity all three of the producer, the repository, and the consumer can compute independently and agree on.

5.28 Package Signatures

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package signature binds a package's bytes to a signing key. Verifying it establishes that the package has not been altered since signing, and that the signer held the trusted private key.

5.28.1 The signature entry #

The signature is the final entry in the tar archive, at .peipkg/signature (§5.12). Its content is a UTF-8 JSON document. Every tar attribute of the entry — mode, owner, mtime, magic — follows the determinism rules of §5.11 unmodified, so the entry is mode 0777 like every other, and §5.16's rationale applies to it identically.

5.28.2 The signed bytes #

The signature is over a SHA-256 hash computed across the concatenation of every complete tar entry block — header, content, and content-block padding to the next 512-byte boundary — for every entry preceding .peipkg/signature, in archive order.

The signed bytes do not include:

  • the tar entry header or content of .peipkg/signature itself;
  • the two trailing zero blocks that terminate a tar archive;
  • any compression artifact — signing operates on the uncompressed tar bytes.

Note

Compressing the signed tar produces the on-wire file. Compression is independent of signing: the same signed tar compressed at different levels verifies identically once decompressed. This is what makes §5.10's inability to fix compression parameters harmless for authenticity.

5.28.3 The envelope #

{
  "schema_version": 1,
  "algorithm": "ed25519",
  "key_fingerprint": "<hex string>",
  "signature": "<base64 string>"
}
FieldDescription
schema_versionMUST be 1 in this version.
algorithmMUST be ed25519 in this version.
key_fingerprintFingerprint of the public key (§5.29). Lowercase hex, 64 characters.
signatureThe signature value, base64 per RFC 4648 §4 without padding. For Ed25519 the decoded value is 64 bytes.

An envelope MUST contain all four fields. A missing field, or an unrecognised algorithm or schema_version, MUST cause the package to be rejected.

5.28.4 Strict parsing #

The envelope MUST NOT contain any field beyond the four above, and MUST NOT contain a duplicate key. An implementation of this version parsing an envelope from a future version MUST reject the package with an error naming the schema version mismatch, rather than silently ignoring the unknown fields.

This is a deliberate exception to §5.9's forward-compatibility rule. For security-critical signing data, strict parsing is preferred to permissive ignoring.

Note

The error must name the version, not the field. An implementation that reports "unknown field x" when the real condition is "this envelope is from a newer specification version" sends the operator looking for a malformed package instead of an outdated tool.

5.28.5 Signing procedure #

To sign a package, a producer:

  1. Constructs every tar entry except .peipkg/signature.
  2. Serialises them as an uncompressed tar byte stream in archive order.
  3. Computes the SHA-256 of that stream.
  4. Signs the resulting 32-byte hash with its Ed25519 private key, per RFC 8032.
  5. Constructs the envelope with the signature value and key fingerprint.
  6. Appends the .peipkg/signature entry — header, JSON content, and padding — to the tar byte stream.
  7. Compresses the complete stream to produce the .peipkg file.

Note that the Ed25519 message is the 32-byte SHA-256 digest, not the tar stream itself. A verifier MUST do the same.

Note

The digest indirection is deliberate and applies to every signature in this chapter, package and detached alike: one signature construction covers a small JSON index and a multi-gigabyte package, and the large case is verifiable in a single streaming pass without buffering.

5.28.6 Determinism #

Given identical signed bytes and an identical key, the Ed25519 signature is deterministic per RFC 8032 §5.1.6. A producer that builds the same tar archive and signs it with the same key MUST produce a byte-identical signature entry.

5.28.7 Unsigned packages #

A package without a .peipkg/signature entry is unsigned.

The format permits unsigned packages. A consumer MAY install one if the originating repository's trust policy permits it (§5.37).

An unsigned package MUST conform to every other requirement of this chapter. The manifest, the files manifest, the payload rules, and the integrity rules apply identically to signed and unsigned packages.

Note

The official repository requires signatures. Other repositories may permit unsigned packages for development, homelab, or air-gapped use. The format is permissive; the policy is enforced by the consumer, per repository.

5.29 Keys and Fingerprints

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

5.29.1 Algorithm #

Signatures use Ed25519 as defined in RFC 8032. A conforming implementation MUST support Ed25519 signing and verification. Other algorithms are reserved for future versions.

Note

Ed25519 was chosen for a small public key (32 bytes) and signature (64 bytes), deterministic output with no nonce-reuse risk, fast verification, resistance to most side channels, and broad library support.

5.29.2 Public key encoding #

A public key is the raw 32-byte Ed25519 public key value of RFC 8032 §5.1.5.

When published as a file, a public key MUST be encoded either as the raw 32 bytes, or as a PEM PUBLIC KEY block per RFC 7468 in the SubjectPublicKeyInfo form. Tooling MUST accept both.

A published public key file MUST contain only the public key, in one of those two encodings.

5.29.3 Fingerprint #

A public key's fingerprint is the lowercase hexadecimal SHA-256 of the raw 32-byte public key:

fingerprint = lowercase_hex(sha256(public_key_bytes))

The fingerprint is 64 hexadecimal characters. It is computed over the raw key bytes and never over a PEM or SubjectPublicKeyInfo encoding of them.

The fingerprint is the canonical identifier of a public key throughout this chapter: the signature envelope's key_fingerprint (§5.28) and the repository descriptor's signing key declarations (§5.31) both use this form.

A consumer that fetches a public key MUST verify the key's fingerprint against the fingerprint that identified it before admitting it to a trust set.

Note

Fingerprints SHOULD be displayed to people in a form that makes a mismatch visually obvious — conventionally four-character groups separated by spaces or colons. The normative on-wire form remains the unbroken 64-character lowercase hex string.

5.29.4 Key roles #

Two roles are distinguished by usage, not by structure:

  • Signing keys are used by a producer to sign packages, descriptors, and indexes.
  • Trusted keys are configured into a consumer as keys whose signatures it accepts.

A single key MAY play both roles.

5.29.5 The trust set #

A consumer maintains a trust set: the public keys whose signatures it accepts.

The trust set MUST be partitioned per repository. Each configured repository contributes its declared signing keys to the trust set, scoped to that repository's content.

A signature MUST be accepted only if its key_fingerprint matches a key in the trust set scoped to the repository the content was fetched from.

Note

Cross-repository acceptance is forbidden. A package fetched from repository R and signed by a key trusted only for repository S MUST be rejected, even though both keys are in the consumer's overall configuration. Without this, a compromised low-trust repository could serve packages whose signatures verify against a high-trust key, which is an escalation from one context to the other.

5.29.6 Private keys #

Private key material is not the concern of this specification. Its generation, storage, custody, and rotation are operational matters for the key holder.

Hardware security modules, threshold signing schemes, and air-gapped signing are all compatible with this format, so long as the resulting signature conforms to the envelope of §5.28. This specification cares about the bytes, not how they were produced.

5.30 Verifying a Signature

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

To verify a package's signature, a consumer MUST:

  1. Decompress the .peipkg file to the uncompressed tar bytes.
  2. Walk the tar archive in order, accumulating each entry's complete blocks — header, content, and content-block padding — until reaching the entry at path .peipkg/signature.
  3. Stop at that entry. What has been accumulated is the signed byte range of §5.28.
  4. Parse the content of .peipkg/signature as the signature envelope.
  5. Validate the envelope's schema_version and algorithm. Reject if either is unrecognised, naming the version mismatch where that is the cause.
  6. Look up the public key by key_fingerprint in the trust set scoped to the originating repository (§5.29). If no matching key is in that trust set, reject.
  7. Determine whether the key is usable for verification given its status (§5.32). Reject a revoked key, and a transitioning key past its validity, before performing any cryptographic operation.
  8. Compute the SHA-256 of the signed bytes.
  9. Verify the signature against that hash with the looked-up key, per RFC 8032.

If step 9 succeeds the signature is valid. If it fails, reject the package.

Note

Step 7 before step 9 is deliberate. A revoked key's signatures are rejected regardless of cryptographic validity, so checking status first means a revoked key can never produce a "signature verified" result anywhere in the implementation, even transiently.

5.30.1 Streaming #

The accumulation in step 2 is conceptual. An implementation MAY hash the signed bytes incrementally as it walks, without retaining the stream; steps 8 and 9 then operate on the running hash state.

Streaming MUST NOT be conflated with early commitment. A consumer hashing incrementally MUST still defer every externally observable filesystem effect until step 9 has succeeded (§5.26).

5.30.2 Locating the end of the signed range #

A verifier computing the signed range by subtracting a fixed header size from a stream offset MUST account for an extended header block preceding the signature entry. §5.11 makes such a header unnecessary for a short-named entry, but a verifier that assumes it away will mis-locate the range for any archive that carries one, and report a signature failure for what is really a framing difference.

5.30.3 Failure conditions #

A consumer MUST reject a package as unverified when any of these holds:

  1. The package contains no .peipkg/signature entry and the trust policy for its originating repository requires signed packages.
  2. The .peipkg/signature entry is not the last named entry in the archive.
  3. The envelope does not parse, or carries an unknown or duplicate field.
  4. The envelope's schema_version is not 1.
  5. The envelope's algorithm is not recognised.
  6. The envelope's key_fingerprint matches no key in the trust set scoped to the originating repository.
  7. The matching key's status does not permit verification.
  8. The cryptographic verification fails.

A rejected package MUST NOT be installed, and the consumer MUST report which condition triggered the rejection.

Note

Condition 2 is not redundant with §5.12's ordering rule; it is the same rule stated where its consequence is visible. Everything after the signature entry is unsigned, so a consumer that tolerates a trailing entry has accepted attacker-chosen bytes inside a package whose signature verifies.

5.30.4 A package with no originating repository #

A consumer MAY accept a package supplied directly rather than fetched from a configured repository — a file handed to it on the command line. Such a package has no originating repository, and therefore no trust set to verify against.

A consumer that accepts one MUST treat it as unverified: it MUST NOT report the package as signature-verified, and it MUST surface to the operator that the package's authenticity was not established.

5.30.5 What verification proves #

A verified signature establishes integrity — the archive bytes preceding the signature entry have not been altered since signing — and authenticity — the signer held the private key corresponding to a trusted public key at the time of signing.

It does not establish that the signed bytes encode meaningful content: a consumer MUST still validate the manifest, the files manifest, and the per-file integrity (§5.26). It does not establish that the signer intended the package for any particular system. And it does not establish that the content is free of bugs or malice. Signing certifies provenance, not safety.

5.30.6 Replay and substitution #

Signature verification alone does not prevent replay — an attacker substituting an older, validly signed package for a newer one. Defence against substitution comes from the repository index (§5.33), which is itself signed, declares the current authoritative version of each package, and records each package's hash.

A consumer MUST consult the index and verify the package's hash against it before accepting the package, even when the signature verifies.

Note

An old signed package whose signature still validates against a still-trusted key is otherwise indistinguishable from the current one. The index's per-package hash binds "what is current" to "this exact file", which is what closes the gap.

5.31 The Repository Descriptor

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A repository descriptor is a small JSON document at a well-known URL describing a repository's identity, its signing keys, and where its indexes live. It is the entry point a consumer fetches when adding or refreshing a repository.

5.31.1 Location #

A repository's descriptor MUST be reachable at <repo-base>/repo.json, where <repo-base> is the base URL the repository was added under (§5.36). It MUST be served as static content.

5.31.2 Schema #

{
  "schema_version": 1,
  "repo": {
    "name": "<string>",
    "description": "<string>",
    "signing": { "algorithm": "<string>", "keys": [<key>...] }
  },
  "indexes": {
    "active":  { "url": "<string>", "signature_url": "<string>" },
    "archive": { "url": "<string>", "signature_url": "<string>" }
  }
}
FieldDescription
schema_versionMUST be 1 in this version.
repo.nameA short identifier for the repository. MUST be non-empty. SHOULD be kebab-case.
repo.descriptionOPTIONAL. A human-readable one-line description.
repo.signingSigning key information.
indexes.activePointer to the active index (§5.33).
indexes.archivePointer to the archive index (§5.35). REQUIRED.

The archive pointer is required even when the archive is empty, as it is for a newly established repository. A repository without an archive index is non-conformant.

5.31.3 The signing object #

{
  "algorithm": "ed25519",
  "keys": [
    { "fingerprint": "<hex>", "url": "<string>", "status": "active" }
  ]
}
FieldDescription
algorithmMUST be ed25519 in this version.
keysOne or more keys. MUST contain at least one with status active.
Key fieldDescription
fingerprintThe key's fingerprint (§5.29): lowercase hex, 64 characters.
urlWhere the public key file is published. MAY be relative to <repo-base>.
statusOne of active, transitioning, revoked (§5.32).
valid_untilRFC 3339 UTC timestamp after which a transitioning key MUST NOT be accepted. REQUIRED for transitioning; ignored otherwise.

The keys array MUST be sorted lexicographically by fingerprint. Two entries with the same fingerprint in one descriptor are invalid.

5.31.4 Index pointers #

FieldDescription
urlWhere the index is published. MAY be relative to <repo-base>.
signature_urlWhere the index's detached signature is published. MAY be relative to <repo-base>.

The conventional URLs are:

<repo-base>/index/active.json
<repo-base>/index/active.json.sig
<repo-base>/index/archive.json
<repo-base>/index/archive.json.sig

A repository MAY use other URLs by declaring them. The descriptor's URLs are authoritative; the conventional paths are defaults for tooling that has nothing else to go on.

5.31.5 Descriptor signing #

The descriptor MUST be accompanied by a detached signature published at <repo-base>/repo.json.sig. The detached signature is a signature envelope (§5.28) over the SHA-256 digest of the descriptor file's exact bytes — the same construction as a package signature.

The signing key MUST be one of the keys listed in the descriptor's own repo.signing.keys, with status active or transitioning.

Note

The descriptor signature defends against an attacker who can serve content from <repo-base> substituting alternate signing keys. Without it, whoever can substitute the descriptor can substitute the keys, and from there re-sign the indexes and the packages. The chicken-and-egg at first add is broken by the operator supplying an expected fingerprint out of band (§5.37).

A repository configured to permit unsigned content MAY publish an unsigned descriptor and unsigned indexes. This is a security weakening opted into per repository, and a consumer MUST NOT treat the absence of a signature as a fetch failure for such a repository.

5.31.6 Canonical form #

The descriptor SHOULD be canonically formatted so that signing is reproducible: fields in the schema's order, key arrays sorted as specified, no trailing whitespace, a single trailing newline.

5.31.7 Naming #

A consumer MAY refer to a repository by a local handle of its own choosing. When it does, it MUST NOT require that handle to equal repo.name, and MUST NOT compare an index's repo field against the local handle. An index's repo field is compared against the descriptor's repo.name.

5.32 Signing Key Status and Rotation

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

5.32.1 Statuses #

A key's status describes its role in the repository's current operation.

StatusUsed for new signaturesAccepted for verification
activeyesyes
transitioningnoyes, until valid_until
revokednonever, whatever the cryptography says
  • active — the key currently signs new packages and indexes. A consumer MUST accept its signatures.
  • transitioning — the key was active and remains acceptable for verification until its valid_until timestamp, but no longer produces new signatures. A consumer MUST accept its signatures while the current time is at or before valid_until, and MUST reject them afterwards. A transitioning key entry MUST carry a valid_until.
  • revoked — the key is no longer trusted under any circumstance. A consumer MUST reject its signatures regardless of when they were produced and regardless of whether they verify cryptographically.

A status other than these three is invalid.

A repository MAY have several active keys, permitting parallel signing; any number of transitioning keys, each with its own valid_until; and any number of revoked keys.

revoked is the explicit signal of a compromise event. transitioning is for routine rotation only, and the two MUST NOT be conflated.

5.32.2 Retention of revoked entries #

A revoked entry MUST be retained in the descriptor for at least one year after the revocation. Removing it prematurely would hide the public acknowledgement of compromise from consumers with stale caches.

A repository MUST continue to serve the public key file of a revoked key for as long as its entry is retained, so that a consumer fetching the descriptor can resolve every key it declares.

Note

Keeping a revoked key in the descriptor is useful even though its signatures are rejected regardless: it is a public acknowledgement a consumer can audit, and it means a consumer encountering a signature from that key gets "this key was revoked" rather than a generic "key not in trust set".

5.32.3 Rotation #

A repository rotates a signing key by:

  1. Generating a new key pair.
  2. Adding the new public key to the descriptor's key list alongside the existing one.
  3. Beginning to sign new content with the new key.
  4. After a transition period during which both are advertised, marking the old key transitioning with a valid_until, and eventually removing it.

During the transition, content signed with either key is acceptable. After the old key's validity lapses, only the new key's signatures remain acceptable.

The length of the transition period is operational policy and is not specified here. Its purpose is to give consumers time to fetch the updated descriptor and learn the new key before old signatures stop being honoured.

5.32.4 The offline emergency key #

A repository SHOULD maintain at least one offline active signing key in addition to its routine signing keys. The offline key's private material is stored separately from build infrastructure and is used only for descriptor updates and emergency rotations.

The offline key exists to break a chicken-and-egg in compromise response. Revoking a compromised signing key requires publishing a new descriptor, which must itself be signed. If the only trusted key is the compromised one, the operator must sign the revocation with the compromised key — giving an attacker who holds that same key the ability to substitute their own revocation that adds a key of their choosing.

With an offline key, the operator signs the descriptor update revoking the compromised key without relying on the compromised key at all. Consumers holding the offline key in their trust set accept the update; consumers who do not must perform an out-of-band trust-anchor refresh (§5.37).

Note

This is SHOULD rather than MUST because the implementation question — hardware module, air-gapped machine, threshold custody — is the operator's. A future version may make it mandatory.

5.32.5 Compromise #

A key SHOULD be considered compromised if its private material may have been obtained by an unauthorised party.

A compromised key MUST be marked revoked in the descriptor immediately on discovery. Packages signed with it SHOULD be re-signed with a fresh key and re-published at new revisions.

The revoked status is the in-band revocation channel this specification defines. It defends at descriptor-update granularity: a consumer that successfully refreshes learns of the revocation at once, and a consumer caching an older descriptor retains trust in the revoked key only until it re-syncs — a window bounded by the maximum trusted age of §5.37.

Note

A richer out-of-band mechanism — signed revocation lists, a key transparency log — is reserved for a future version. The combination of revoked status, a bounded maximum trusted age, and signed descriptor updates closes the practical compromise-response window without requiring separate revocation infrastructure.

The corollary a consumer must not miss: revocation only protects the paths that consult the trust set. Any code path that installs a package without resolving its key against the repository's trust set — a build or image-composition path, say — is a path on which revocation has no effect at all.

5.33 The Active Index

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

The active index lists the current version of every package a repository advertises. It is the index a consumer fetches on a routine sync.

5.33.1 Location and signing #

The active index's URL and its detached signature's URL are declared by the descriptor (§5.31).

The index MUST be accompanied by a detached signature: a signature envelope (§5.28) over the SHA-256 digest of the index file's exact bytes. The signing key MUST be one of the descriptor's keys with status active or transitioning.

A repository configured to permit unsigned content MAY publish the active index unsigned.

Note

Detached metadata signatures and package signatures share a single construction — an envelope over a SHA-256 digest — so a verifier implements it once. The digest indirection is free for a small JSON index and lets a multi-gigabyte package be verified in one streaming pass.

5.33.2 Schema #

{
  "schema_version": 1,
  "repo": "<string>",
  "kind": "active",
  "index_version": <integer>,
  "generated_at": "<RFC 3339 timestamp>",
  "packages": [<package_entry>...]
}
FieldDescription
schema_versionMUST be 1 in this version.
repoThe repository's name, matching repo.name in the descriptor.
kindMUST be active.
index_versionA monotonically increasing positive integer identifying this index revision (§5.34).
generated_atRFC 3339 UTC timestamp of generation.
packagesOne entry per package currently advertised.

A consumer MUST verify that repo matches the descriptor's repo.name and that kind matches the index it requested. An archive index served in place of an active one MUST be rejected.

5.33.3 Package entries #

{
  "name": "<string>",
  "version": "<string>",
  "architecture": "<string>",
  "description": "<string>",
  "license": "<string>",
  "license_class": "<string>",
  "homepage": "<string>",
  "default_root": "<root reference>",
  "alternate_upgrade": {<alternate_upgrade>},
  "dependencies": [<dependency>...],
  "optional_dependencies": [<dependency>...],
  "conflicts": [<dependency>...],
  "provides": [<provides>...],
  "replaces": [<replaces>...],
  "side_effects": [<string>...],
  "size_compressed": <integer>,
  "size_installed": <integer>,
  "hash": { "algorithm": "<string>", "value": "<hex string>" },
  "url": "<string>",
  "build": { "timestamp": "<RFC 3339>", "farm_id": "<string>" }
}

An entry MUST contain name, version, architecture, dependencies, conflicts, provides, replaces, side_effects, size_compressed, size_installed, hash, and url. The array fields MUST be present even when empty, emitted as []. The remaining fields are RECOMMENDED and MAY be omitted.

name, version, and architecture MUST each be validated against §5.3, §5.5, and §5.8 respectively on parse — with the same strictness a manifest receives. An index is fetched from the network and its values flow into URL construction and into the consumer's own records.

size_compressed and size_installed are required because they are the input to the decompression bound of §5.27.

license_class, when present, MUST be validated against the closed set of §5.18 on parse; absent means unknown, as in the manifest. It is carried so a consumer can apply a licence policy at planning time, before fetching anything.

alternate_upgrade, when present, MUST be validated exactly as the manifest's (§5.18). It is carried so a consumer can refuse or hold back a package (§5.18 rules 1 and 2) at planning time, before fetching anything, and so an every-package upgrade can report what it held back without downloading it.

hash carries algorithm, which MUST be sha256 in this version, and value, the lowercase hexadecimal SHA-256 of the .peipkg file in its compressed on-wire form.

5.33.4 The derivation rule #

The active index is a derived view of the packages it advertises. Every field of an entry MUST exactly match the corresponding field of that package's manifest where one exists, and MUST exactly match the properties of the actual package file for hash, size_compressed, and url.

Tooling generating an index MUST extract values directly from package manifests. Editing an index by hand is forbidden.

Where a manifest contradicts an index entry, the manifest is authoritative (§5.18) — and the contradiction is a defect in the repository, not a difference to accommodate. A consumer MUST compare the downloaded package's manifest against the index entry that led to it, across every field the index carries, and MUST reject the package on any mismatch (§5.26 step 8).

Note

Comparing only name, version, and architecture is not enough, and the gap is not theoretical. A consumer builds its entire dependency and conflict graph from index claims. A repository that publishes an entry declaring no conflicts for a package whose manifest declares one against a critical installed package gets a plan computed on the lie, approved by the operator on that basis, and applied — with the real relations discovered by nobody. The index is a convenience for planning without downloading; it is not a second source of truth.

5.33.5 Deliberate omissions #

The index omits three manifest fields:

  • sd_overrides — not relevant to planning, and potentially large.
  • build.source_ref — long and low in information density; consult the package when it is wanted.
  • the manifest's own schema_version — the index carries its own.

These remain in the manifest and are available to a consumer that fetches the package. Because they are omitted rather than mismatched, they are outside the comparison above.

5.33.6 URLs #

url declares where the package file is fetched from, and MAY be relative or absolute (§5.36). The conventional form is relative:

"url": "/p/nginx/1.26.2-3/nginx_1.26.2-3_x86_64.peipkg"

This keeps an index portable: the same file is valid at any <repo-base> hosting the same package files.

5.33.7 Ordering #

The packages array MUST be sorted lexicographically by name. Two entries with the same name in an active index are invalid: each name appears exactly once.

Note

Per-name uniqueness is what makes the index "active" — one current version of each package. The archive index (§5.35) relaxes exactly this constraint and nothing else.

5.33.8 Unknown fields #

A consumer MUST ignore unknown fields, at the top level and per package, per §5.9. A producer MAY emit additional fields in a future schema version.

The exception is a field whose meaning is critical to correctness, such as a hash algorithm identifier. Such changes are expected to arrive through a schema_version bump, not as a silent addition.

5.33.9 Size and caching #

For a repository of a few hundred packages the active index is on the order of 100 KB compressed. A consumer SHOULD fetch with HTTP-level compression where it is offered, and SHOULD cache the parsed index between invocations: the index changes only when the repository publishes, which is far less often than a consumer reads.

A cached index MUST be stored under a security descriptor granting write access only to the principal permitted to install packages.

A consumer MUST re-verify a cached index's signature on every operation that relies on it, rather than trusting its cached state across operations. Caching avoids re-parsing; it does not avoid re-verifying.

A consumer SHOULD additionally cross-check a cached index against its own recorded freshness state (§5.34), and reject a cached index whose index_version or generated_at disagrees with what it recorded.

Note

Without re-verification, whoever can write to the cache can substitute metadata between the cache write and the next read. Re-verifying on every use closes that race, and the cost is negligible: verifying a signature over a few hundred kilobytes is sub-millisecond. The cross-check against recorded state closes the matching hole, where an attacker substitutes an older validly signed index directly into the cache, bypassing the refresh path where §5.34's floor is enforced.

5.34 Freshness and Rollback Protection

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

An index that verifies is not necessarily current. This section defends against rollback — replaying an older signed index to hide newer packages — and freeze — holding a consumer at a current-but-stale index while its clock runs on.

Every requirement here applies to both the active index and the archive index. An attacker who can replay one can replay the other, and the archive index is the candidate source for every downgrade and pin.

5.34.1 Monotonic index versions #

Each publication of an index MUST set index_version to a value strictly greater than any previously published value for the same repository.

A consumer MUST record, per repository, the highest index_version it has ever observed. On each fetch it MUST reject an index whose index_version is less than that recorded value, even when the index is correctly signed by a still-trusted key.

A consumer MUST also record the generated_at of the last index it trusted, and MUST reject an index whose generated_at is older than the recorded value.

5.34.2 No progress is a failed fetch #

A fetch returning an index whose index_version and generated_at both equal the recorded values is a failed refresh, not a successful one. A consumer MUST NOT advance its "last successful refresh" timestamp on such a fetch.

Note

This is the anti-freeze rule, and it is the one most often skipped. Without it, an attacker who can serve the same signed index indefinitely keeps every consumer's refresh timestamp advancing while the content never changes, so the maximum-trusted-age check of §5.37 never fires and the consumer never notices it has been pinned.

5.34.3 The initial floor #

Adding a repository bootstraps the consumer's recorded floor. To defend against an attacker serving a stale-but-signed index at that moment, a repository SHOULD distribute a minimum acceptable index_version alongside its trust anchors, through the same out-of-band channel. A consumer SHOULD use that minimum as its initial floor, and MUST refuse the add when the first index fetched falls below it.

A consumer MUST NOT reset a recorded floor as a side effect of any operation other than removing the repository. In particular, re-adding an already-configured repository MUST NOT lower the floor: the operation either applies the recorded floor as a refresh would, or is refused.

Note

Repository-add reads as idempotent, and configuration-management convergence loops treat it that way. If adding an already-known repository rewrites the floor unconditionally, a rollback that the refresh path correctly refuses becomes permanent and invisible the next time that loop runs. Removing the repository first is the sanctioned reset (§5.37), because it discards the trust state too.

5.34.4 Maximum index staleness #

A consumer MUST enforce a maximum staleness window on the index itself, measured from its generated_at. An index older than 90 days MUST trigger a refresh attempt before any install operation proceeds.

The 90-day default MAY be tuned by operator configuration; a value greater than 365 days SHOULD generate a warning each time it is exercised.

Note

This is a different measurement from the maximum trusted age of §5.37, and both are needed. Trusted age asks "how long since I successfully refreshed"; index staleness asks "how old is the metadata I am acting on". A repository that bumps index_version on every publication while stamping an ancient generated_at satisfies the first check forever and fails the second immediately.

5.34.5 What these checks buy #

Per-package signing and index signing both still verify under a rollback: the attacker is replaying genuine, correctly signed content. What changes is the set of packages the consumer believes is current. The monotonic version check is what makes that set unable to move backwards, and the no-progress rule is what stops it from being frozen in place.

5.35 The Archive Index

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

The archive index lists every version of every package a repository has ever advertised, including versions superseded by newer releases. It is the source of historical data for downgrade, version pinning, and forensic queries.

5.35.1 Retention #

A repository MUST retain every package version it has ever advertised. Once a package has been published at version V, the repository MUST continue to make V fetchable indefinitely, and the archive index MUST continue to list it.

Note

This is a deliberate departure from rolling-only models. Retention is what supports rollback, reproducible deployment, security forensics, and long-running systems held at an older version.

A repository MAY retire pre-release or development versions under a stated retention policy. Retirement MUST NOT silently remove a package a consumer might be using, and SHOULD be coordinated with consumer notice.

A pruned package MUST also be removed from the repository's package storage: the archive index MUST NOT reference a package file that is no longer fetchable.

Note

Reasonable policies include retaining all stable releases indefinitely, retaining pre-releases for a year after each successor, or never pruning anything. This specification neither mandates nor forbids pruning.

5.35.2 Location and signing #

The archive index's URL and its detached signature's URL are declared by the descriptor (§5.31). It MUST be signed under the same rules as the active index (§5.33).

5.35.3 Schema #

The top-level schema is identical to the active index (§5.33), except:

  • kind MUST be archive;
  • the packages array MAY contain several entries with the same name, at different versions.

The per-package entry schema is identical. Each historical version contributes one entry.

index_version semantics are identical, and every freshness and rollback requirement of §5.34 applies to the archive index exactly as it does to the active one.

5.35.4 Ordering #

The packages array MUST be sorted lexicographically first by name, then within a name by version descending per §5.6. The first entry for any name is its highest version; subsequent entries for that name are progressively older.

Where two entries of one name share a version — differing only in architecture — the ordering between them MUST be total and MUST be stated by the producer's tooling, so that the file is reproducible.

Note

Highest-first ordering puts the most recently shipped version of each package at the top of its name group, so a consumer scanning for the latest version satisfying a constraint can stop as soon as the constraint is satisfied or exceeded.

5.35.5 Relationship to the active index #

For every entry in the active index there MUST be at least one entry in the archive index with the same name, version, architecture, and hash. The archive index is a superset of the active index.

Equivalently: the active index is the per-name maximum projection of the archive index, where "maximum" is the highest version per name under §5.6.

A repository publishing both indexes SHOULD publish them at the same index_version and generated_at, so that a consumer holding one has a usable floor for the other.

5.35.6 Fetch frequency #

The archive index is large compared to the active index — potentially many megabytes for a long-running repository. A consumer SHOULD fetch it only when it is needed: for a historical query, for a pin or a downgrade, or when its cached copy expires. A routine sync SHOULD fetch only the active index.

A consumer SHOULD cache the archive index aggressively, since it changes only when a version is published or pruned.

5.36 URL Conventions

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every URL in this chapter maps to a static file. The protocol requires no server-side computation, no dynamic response, and no content negotiation beyond optional HTTP-level compression.

5.36.1 The repository base #

A repository is identified by a base URL, <repo-base>.

The base URL MUST be a syntactically valid HTTP or HTTPS URL per RFC 3986, and MUST NOT have a trailing slash: the well-known relative paths below are appended directly.

HTTPS MUST be used, unless the consumer has been configured with an explicit per-repository insecure-transport allowance. There is no global form of that allowance, and its use MUST generate a per-operation warning.

Enabling the allowance on a repository that has already been added MUST require explicit operator authorisation and MUST emit an audit event. Setting it as part of the initial add is covered by the operator's trust decision at that moment and requires no separate event beyond the add's own.

Note

Insecure transport is intended for a trusted local network during development. Relying on package signing alone for transport integrity leaves the consumer exposed to traffic analysis and to metadata-substitution attacks even when content verification succeeds.

A consumer MAY additionally support a file:// base URL for local development. A file:// repository MUST be subject to the same per-repository allowance as an HTTP one: it is not HTTPS, and admitting it silently makes removable or network-mounted media a trusted source without the operator ever acknowledging it.

5.36.2 Conventional paths #

PathContent
<repo-base>/repo.jsonRepository descriptor (§5.31)
<repo-base>/repo.json.sigDetached signature on the descriptor
<repo-base>/index/active.jsonActive index (§5.33)
<repo-base>/index/active.json.sigDetached signature on the active index
<repo-base>/index/archive.jsonArchive index (§5.35)
<repo-base>/index/archive.json.sigDetached signature on the archive index
<repo-base>/keys/<fingerprint>.pubPublic key file, named by full fingerprint
<repo-base>/p/<name>/<version>/<filename>Package file

A repository SHOULD use these paths unless it has a reason not to; when it does not, the descriptor declares the ones it uses. A consumer that knows only <repo-base> MUST be able to locate repo.json at the conventional path. The descriptor carries the URLs for everything else.

5.36.3 Package URLs #

<repo-base>/p/<name>/<version>/<filename>

where <name> conforms to §5.3, <version> is the full version string of §5.5, and <filename> is <name>_<version>_<architecture>.peipkg.

https://pkgs.peios.org/p/nginx/1.26.2-3/nginx_1.26.2-3_x86_64.peipkg

5.36.4 Sibling artifacts #

The directory containing a package file MAY hold additional siblings for that version. These are reserved for future use and are not normative here:

<repo-base>/p/<name>/<version>/<filename>.debug.peipkg
<repo-base>/p/<name>/<version>/<filename>.sbom.json
<repo-base>/p/<name>/<version>/<filename>.attestation.json

A consumer conforming to this version MUST NOT attempt to fetch a sibling artifact. A producer MAY publish them; their meaning is defined by a future version.

5.36.5 Relative URLs #

A URL field in a descriptor or an index MAY be absolute or relative.

  • An absolute URL, carrying a scheme, is used as-is.
  • A URL beginning with / is resolved against <repo-base> by prepending the base.
  • A URL with neither a scheme nor a leading / is resolved against the URL of the document containing the reference, per RFC 3986 §5.

Note

Relative URLs are RECOMMENDED: they keep an index portable, valid under any <repo-base> hosting the same layout. An absolute URL pins the index to a host and requires regeneration when the host changes.

5.36.6 Hosting #

A conformant repository may be hosted on a plain HTTP server, an object store with an HTTP frontend, a static site host, a CDN in front of any of those, or a combination — descriptor and indexes on a static host, package files on object storage behind redirects.

5.36.7 Network failure #

A consumer that fails to fetch a URL MUST NOT silently fall back to outdated cached data. Using a stale cache without explicit operator consent can mask substituted content or a revoked-key update.

A consumer SHOULD offer a way to configure cache-staleness tolerance per repository.

A consumer whose cached index for a configured repository fails to load or verify MUST treat that as a failure of the operation rather than proceeding without that repository.

Note

Silently continuing is worse than it looks. Dropping a repository from consideration mid-operation does not merely lose candidates: it can silently promote a lower-priority repository's package into a role the dropped one was filling, which is an escalation dressed as a warning.

5.37 Establishing Trust in a Repository

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Trust is configured per repository, never globally. Each repository a consumer is configured with has its own trusted signing keys (§5.29), its own signature policy, and its own priority.

5.37.1 Adding a repository #

To add a repository, the operator supplies its <repo-base> URL, one or more expected key fingerprints — the trust anchors — and a signature policy.

The consumer then:

  1. Fetches <repo-base>/repo.json and <repo-base>/repo.json.sig.
  2. Fetches the public key for each supplied anchor fingerprint, from the conventional URL or from the URL the descriptor declares, and verifies each fetched key against the fingerprint that named it (§5.29).
  3. Verifies the descriptor's signature against those anchor keys and only those.
  4. On success, records the descriptor's contents — including every signing key and status — as the repository's initial trust state.
  5. On failure, rejects the add and reports why.

A consumer MUST NOT add a repository whose signing key it learned from the repository itself without prior verification against an anchor. Trust anchors are obtained out of band: through a project's website, its documentation, or the operating system image.

Note

Step 2 necessarily issues requests derived from an unverified document, since the descriptor is what names the key URLs. A consumer SHOULD limit those requests to the keys matching the supplied anchors, and MUST NOT follow a key URL for a fingerprint the operator did not name — otherwise a substituted descriptor can direct the consumer to fetch from arbitrary hosts before anything has been verified.

5.37.2 Guarding against a mistyped fingerprint #

When presenting a fetched key for confirmation, a consumer MUST:

  • display the 64-character fingerprint in groups separated by spaces or colons — conventionally four characters per group, as 1a2b 3c4d 5e6f ...;
  • display the fetched key's fingerprint alongside the one the operator supplied, for visual comparison, before recording any trust state;
  • require explicit confirmation before recording. Automatic confirmation on the basis of a bit-for-bit match is permitted only in a non-interactive context where the operator pre-supplied the fingerprint through a configured channel.

When a repository declares several active keys, the operator is RECOMMENDED to supply anchors for at least two of them, as defence-in-depth against a single mistyped anchor.

A consumer MUST report an anchor mismatch by naming both the anchor the operator supplied and the fingerprints the descriptor actually declares. A mismatch is most often a transcription error, and that is precisely the diagnostic needed to find one.

5.37.3 Signature policy #

PolicyMeaning
requiredEvery package and index MUST be signed and verify. Unsigned content from this repository is rejected.
optionalSigned content is verified. Unsigned content is accepted with a per-operation warning.

These are the only two policies. There is no silently-accept-unsigned policy: a consumer intentionally permitting unsigned content does so through optional, which always warns.

The warning MUST surface on every install, upgrade, and refresh that accepts unsigned content — not once per session — so that a misconfigured trust state stays continuously visible.

A consumer's default policy for a newly added repository SHOULD be required unless the operator explicitly chooses otherwise, and the official repository SHOULD be configured required.

optional means signed content is verified. A consumer MUST NOT treat the absence of trust anchors as licence to stop verifying: a repository under optional that publishes signatures MUST have them verified, and one that publishes none MUST produce the warning rather than a fetch error.

Note

The two failure modes here are mirror images and both are real. A consumer that demands a signature file under optional cannot add a repository that §5.31 explicitly permits to publish none. A consumer that stops verifying entirely because no anchors were configured leaves a repository fully substitutable by anyone on the network path, permanently, even after it starts publishing good signatures.

5.37.4 Refresh #

A consumer SHOULD refresh its cached repository state periodically. A refresh MUST:

  1. Fetch the current descriptor and its signature.
  2. Verify the signature against any key whose status was active or transitioning in the previously trusted descriptor.
  3. On success, record the new descriptor as the current trust state, replacing the previous key set with the new one.
  4. Fetch the active index and verify it against the new descriptor's keys, applying §5.34.
  5. Optionally fetch and verify the archive index, applying §5.34 to it as well.

A failed refresh MUST leave the previous trust state in place and be reported. A consumer MUST NOT fall back to unverified state.

Note

A failed refresh may mean the repository is unavailable, the network is interrupted, or the signing key was rotated to one the previously trusted set does not contain. These are distinct operational concerns; the consumer surfaces the failure and lets the operator distinguish them.

5.37.5 Maximum trusted age #

A consumer MUST track the time of the last successful refresh per repository. When that exceeds the maximum trusted age, the consumer MUST attempt a refresh before any install, upgrade, or downgrade against that repository. If the attempt fails, the consumer MUST report the failure and refuse the operation, unless the operator explicitly authorises proceeding on stale trust state.

The default maximum trusted age is 30 days. It MAY be tuned by operator configuration; a value above 180 days SHOULD produce a per-operation warning, so that a configuration effectively disabling the check stays visible.

Note

The maximum trusted age bounds the window in which a compromised-but-not-yet-revoked key can be used against a consumer that has not refreshed. Without it, a long-offline consumer trusts a rotated key indefinitely.

5.37.6 Priority #

A consumer MAY configure several repositories. Each has a numeric priority: a positive integer, where a lower number is a higher priority.

A consumer's default assignment SHOULD give the official repository the lowest number. Other repositories receive priorities at the operator's discretion.

5.37.7 Removal #

A consumer MAY remove a configured repository at any time. Removal deletes the cached state and the trust set scoped to that repository. It does not uninstall packages already installed from it; those remain installed, and their origin is retained.

Re-adding a removed repository performs the full trust ceremony afresh; previous state is not implicitly restored.

5.37.8 Orphaned packages #

A package whose originating repository has been removed or revoked is orphaned: its trust chain is no longer verifiable by the current trust state. A consumer MUST:

  • display an orphaned package with a clear indicator in query output;
  • surface the orphan state on any operation involving it, and recommend an audit before proceeding;
  • refuse an upgrade to an orphaned package unless a currently trusted repository now claims it by name.

A consumer MUST NOT treat an unknown origin as an absent origin. Wherever this chapter gates an operation on the relative priority of two repositories, an orphaned package's origin MUST be treated as at least as trusted as any configured repository, so that the gate still fires.

Note

The failure this prevents is subtle and severe: a repository removed because its keys were stolen leaves packages behind whose origin no longer resolves. If an unresolvable origin is quietly treated as lowest-priority, every cross-repository guard below stops firing for exactly those packages — so revoking a repository would lower the protection on what it left behind, and any newly added low-trust repository could take an orphaned ex-official package over without confirmation.

Operators meeting an orphaned package SHOULD audit it: verify the installed files' hashes against trustworthy out-of-band records, and consider reinstalling or removing it through a trusted repository.

5.37.9 Between repositories #

When two configured repositories publish a package of the same name, no conflict exists at the format level; the consumer resolves which to install by priority. The same applies to overlapping provides or replaces relations: the higher-priority repository's claim wins.

An operator publishing a provides that shadows a package of the official repository SHOULD document it clearly, and a consumer SHOULD warn when a lower-priority repository's provides shadows a higher-priority package.

Two guards require explicit operator confirmation, and neither may be satisfied by a general "proceed" affirmation:

  • Applying a replaces declared by a lower-priority repository against a package originally installed from a higher-priority one. A repository silently replacing a more-trusted package is a real escalation path, and confirmation stops it happening as a side effect of a routine upgrade.
  • Applying a conflicts declared by a lower-priority repository that would cause the cascade-removal of a package from a higher-priority one. That is a denial-of-availability vector, and confirmation stops a low-trust install from silently uninstalling a high-trust package.

A consumer that resolves a conflict by rejecting the plan outright, rather than by cascading removals, satisfies the second guard vacuously.

5.37.10 Compromise response #

If a repository's signing key is suspected of compromise, a consumer SHOULD disable the repository immediately, audit the packages installed from it for tampering, and, once the operator has published a new descriptor with the compromised key removed, perform a fresh trust-add with new anchors.

This version defines no automated revocation mechanism beyond the revoked key status (§5.32). Compromise response is operational.

5.38 Extension

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every document in this chapter carries a schema_version, currently 1. A change that a conforming implementation of this version cannot process correctly requires a version bump; a change it can safely ignore does not.

5.38.1 Additive changes #

The following are additive and do not require a version bump:

  • A new optional field in the manifest, an index entry, or the repository descriptor. A consumer ignores it (§5.9).
  • A new optional metadata entry under .peipkg/ (§5.12). A consumer ignores it, and its presence MUST NOT prevent installation.
  • A new sibling artifact alongside a package file (§5.36).

A producer emitting an additive extension MUST ensure that a consumer ignoring it still behaves correctly. An extension whose omission changes what gets installed is not additive.

5.38.2 Changes requiring a version bump #

  • Adding, removing, or changing the meaning of a required field.
  • Adding a value to a closed enumeration: the side-effect identifiers of §5.24, the architecture identifiers of §5.8, the hash algorithms of §5.25, the signature algorithms of §5.28, the key statuses of §5.32, the index kinds, the constraint operators of §5.7, the licence classes of §5.18, or the signature policies of §5.37. A conforming implementation of this version MUST reject a value outside each of those sets, so a new value is not ignorable.
  • Any change to the version comparison algorithm of §5.6, which is frozen.
  • Any change to the determinism rules of §5.11, which decide the bytes.
  • Any change to the signature envelope of §5.28, which is strictly parsed by construction.

5.38.3 Reserved space #

This version reserves syntactic room in three places, so that a future extension can be additive where it would otherwise not be:

  • The algorithm fields of the files manifest and the index hash object reserve room for a further hash algorithm.
  • The arch qualifier on a dependency reserves room for explicit architecture identifiers, for a multi-architecture system.
  • The sibling-artifact paths of §5.36 reserve room for build attestations and bills of material.

An implementation of this version MUST reject a value in a reserved space rather than guess at it.

5.38.4 Deprecation #

A field this specification requires MUST NOT be removed within a schema_version. When a field becomes unnecessary, a producer continues emitting it and a future version removes it under a new schema_version.

5.39 Conformance

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

5.39.1 Producer #

A conforming producer:

  • emits packages satisfying §5.10 through §5.17: the container, every determinism rule, the internal layout, the payload path constraints, the install destinations, the triplet rule, the entry rules, and the symlink rules;
  • emits a manifest satisfying §5.18, with names, versions, and architectures satisfying §5.3 through §5.8;
  • emits a files manifest satisfying §5.25, covering exactly the regular-file payload entries, with size_installed equal to the sum of its sizes;
  • declares relationships satisfying §5.21, sorted and unique within each field, using the derived-capability names of §5.22 where a capability is machine-derived;
  • declares claims satisfying §5.23, with every target a payload path of its own;
  • declares side effects satisfying §5.24, declaring each that its payload requires and none that it does not;
  • signs packages satisfying §5.28, or emits them unsigned knowing they will be accepted only under a permissive policy.

5.39.2 Repository #

A conforming repository:

  • publishes a descriptor satisfying §5.31 with a valid detached signature, and serves the public key file of every key the descriptor declares, including revoked ones (§5.32);
  • publishes an active index satisfying §5.33 and an archive index satisfying §5.35, each with a valid detached signature, each derived directly from package manifests;
  • increases index_version strictly on every publication (§5.34);
  • retains every version it has ever advertised, and removes a pruned package from both its archive index and its storage (§5.35);
  • serves everything over HTTPS at the URLs its descriptor declares (§5.36).

5.39.3 Consumer #

A conforming consumer:

  • verifies a package by §5.26 in full, including the transaction-wide rule and the path-resolution rule, before installing anything;
  • enforces the decompression bounds of §5.27 continuously, from the index-declared sizes;
  • verifies signatures by §5.30, against a trust set scoped to the originating repository, honouring key status before any cryptography;
  • rejects a package violating any rule of §5.10 through §5.25 — the determinism rules and the payload rules included, on the way in, not only on the way out;
  • enforces the freshness and rollback rules of §5.34 on both indexes, and never lowers a recorded floor except by removing the repository;
  • establishes and maintains trust by §5.37, including the fingerprint comparison, the per-operation warnings, the maximum trusted age, the orphan rules, and the two cross-repository guards;
  • enforces the security descriptor policy of §5.20;
  • invokes side effects by §5.24, once per transaction, by fixed absolute path, with a cleared environment, against the root the transaction acted on;
  • materialises claims by §5.23, and never at a path an installed package owns.

5.39.4 What conformance does not require #

A conforming consumer is not required to resolve dependencies by any particular algorithm, to store its state in any particular form, to recover from an interrupted operation by any particular mechanism, or to offer any particular command surface. Those are its own design, and §5.1 places them outside this chapter deliberately.

What it is required to do is reach the same answer as any other conforming consumer about whether a given package satisfies a given dependency (§5.21), and about which of two versions is newer (§5.6). Those two questions are the ones a producer's declarations depend on, and they are frozen.

Appendix 5.A Limits and Defaults

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every limit below is a minimum conformance figure: a consumer MUST process a package or document whose characteristics fall within it, and MUST reject one that exceeds it.

A consumer MAY raise a limit through operator configuration, but MUST NOT raise one silently: an operator-tuned value SHOULD be logged and surfaced in diagnostic output.

A producer SHOULD stay well below these figures. They exist to bound a consumer's resource use when processing a maliciously crafted package, not to describe the scale of a well-formed one.

5.A.1 Package structure #

LimitMaximum
Payload entries100,000
.peipkg/manifest.json size16 MiB
.peipkg/files.json size64 MiB
.peipkg/signature size64 KiB
Single payload path component (UTF-8 bytes)255
Complete payload path (UTF-8 bytes)4096
Path nesting depth (components)256
Single claim path (UTF-8 bytes)4096

5.A.2 Manifest arrays #

LimitMaximum
dependencies10,000
optional_dependencies10,000
conflicts10,000
provides10,000
replaces1,000
sd_overrides100,000
Single sd_override decoded sd length64 KiB
Slots per claims field64
Claim paths materialised per role256

The claim-path figure is a materialisation limit, not a manifest limit: it bounds the union computed across every installed package declaring a path for that role, which is the quantity an adversary controls by installing many consumer-only packages.

5.A.3 Identity #

LimitValue
Package name length2 to 64 characters
Virtual name length2 to 128 characters
Architecture identifier lengthat most 16 characters

5.A.4 Documents #

LimitValue
JSON nesting depth64
Integer field rangeunsigned 64-bit

5.A.5 Decompression #

BoundValue
Compressed overrun allowance over size_compressedthe lesser of 1% or 16 MiB
Decompressed overhead allowance over size_installed320 MiB
Absolute decompressed cap4 GiB (default; operator-tunable)

5.A.6 Repository defaults #

DefaultValue
Maximum trusted age30 days
Maximum trusted age producing a warningabove 180 days
Maximum index staleness90 days
Maximum index staleness producing a warningabove 365 days
Revoked key retentionat least 1 year
Repository prioritypositive integer; lower is higher priority
Default signature policy for a new repositoryrequired

Appendix 5.B Enumerated Values

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every set below is closed in this version. A conforming implementation MUST reject a value outside it, and a new value requires a schema_version bump (§5.38).

5.B.1 Architecture identifiers #

IdentifierTripletNotes
x86_64x86_64-linux-peiosprimary target
aarch64aarch64-linux-peiossecondary target
noarchnonearchitecture-independent

Defined in §5.8.

5.B.2 Pre-release rank tokens #

TokenRank
dev0
alpha1
a1
beta2
b2
pre3
rc4
any other alphabetic segment5

Rank 0 sorts lowest. Rank-5 tokens compare lexically against each other. Recognition is case-insensitive. Defined in §5.6.

5.B.3 Constraint operators #

OperatorMeaning
=exactly equal
>strictly greater than
>=greater than or equal
<strictly less than
<=less than or equal
!=not equal

A bare version with no operator means =. Comma is the AND separator. Defined in §5.7.

5.B.4 Side-effect identifiers #

IdentifierDeclared whenInvoked as
depmodthe payload contains kernel modules (MUST)once per affected kernel release, naming it
man-dbthe payload contains man pages (SHOULD)the tool, in quiet mode

Defined in §5.24.

5.B.5 Licence classes #

ClassMeaning
unknownNothing asserted. The meaning of an absent license_class.
freeFree and open-source software.
firmwareRedistributable device firmware: no source, executes on a device rather than the CPU, needed to use hardware the user owns.
proprietaryNon-free software that runs on the CPU.

Defined in §5.18.

5.B.6 Hash algorithms #

AlgorithmIdentifierStatus
SHA-256sha256REQUIRED; the only valid value
BLAKE3blake3RESERVED for a future version

Defined in §5.25.

5.B.7 Signature algorithms #

AlgorithmIdentifierStatus
Ed25519ed25519REQUIRED; the only valid value

Defined in §5.29.

5.B.8 Signing key statuses #

StatusSigns new contentAccepted for verification
activeyesyes
transitioningnountil valid_until
revokednonever, regardless of cryptographic validity

Defined in §5.32.

5.B.9 Index kinds #

KindContent
activethe current version of each package
archiveevery version ever shipped

Defined in §5.33 and §5.35.

5.B.10 Signature policies #

PolicyUnsigned content
requiredrejected
optionalaccepted with a per-operation warning

There is no silently-accept-unsigned policy. Defined in §5.37.

5.B.11 Reserved metadata paths #

PathRequired
.peipkg/manifest.jsonyes
.peipkg/files.jsonyes
.peipkg/signaturein every signed package

The .peipkg/ prefix is reserved; a payload entry MUST NOT use it. Defined in §5.12.

5.B.12 Permitted entry types #

TypeTypeflag
Regular file0 or \0
Directory5
Symbolic link2

Every other type MUST cause the package to be rejected. Defined in §5.12.

5.B.13 Permitted top-level install destinations #

/usr/bin/, /usr/sbin/, /usr/lib/<triplet>/, /usr/lib/debug/, /usr/lib/modules/<release>/, /usr/lib/firmware/, /usr/lib/os-release, /usr/libexec/, /usr/share/, /usr/include/, /usr/src/debug/, /usr/src/dist/, /usr/etc/, /usr/conf/, /var/, /boot/, /hooks/, /++/.

A payload entry MUST NOT install under any other top-level path, unless the package declares itself a special system package and the operator has separately opted in. /lcl/policy is unreachable under every circumstance. Defined in §5.14.

5.B.14 Permitted claim path locations #

The destinations above, plus /run/ and the well-known root-level name /init. Defined in §5.23.

6.1 Scope and Roles

Peios / Peios technical reference / PSPU / Name Resolution Interface

This chapter specifies the Name Resolution Interface (NRI): the protocol by which a program asks the machine's resolver for the records behind a name, the protocol by which the network manager tells the resolver what each interface contributes, and the obligations of the resolver that stands between them.

Three roles participate.

The resolver is the one process on a machine that answers names. It is a stub resolver — a DNS client — not a recursive or authoritative server: it answers synthetically, from the registry, from its cache, or by forwarding to an upstream server chosen by policy. It listens on a Unix socket (§6.4) and on the loopback DNS port (§6.8); it never dials a client. Peios' resolver is resolvd; another implementation MAY stand in its place, and the obligations in §6.11 are what "stand in its place" means.

A client is any process that asks. Most clients never see this protocol: they call getaddrinfo, and the NSS shim (§6.10) speaks it for them. Peios-native tools, the operator command and the network manager speak it directly. A client is not trusted by the resolver beyond what the control object grants it (§6.4).

The network manager is the process that knows what each interface contributes — servers, search domains, addresses, whether it carries the default route. It publishes that to the resolver over the manager's own control socket (§6.9); the resolver subscribes. Nothing about the live network is ever written to the registry to get from one to the other.

This chapter covers:

  • the three doors and the one function behind them (§6.3)
  • the native channel: socket, framing, access (§6.4)
  • the requests and their replies (§6.5)
  • what an outcome and a validation state mean (§6.6)
  • the resolution model: scopes, routing, expansion, synthetic names, the cache (§6.7)
  • the stub door (§6.8)
  • the network manager channel (§6.9)
  • what the hosts shim must and must not do (§6.10)
  • conformance, by role (§6.11)
  • a message reference and the limits (§6.A, §6.B)

What this chapter does not specify: DNSSEC validation and encrypted transports (reserved; a resolver reports unvalidated until it does them), multicast DNS, and per-caller resolution policy. Each has a place held for it in the wire format so that adding it is additive.

6.2 Terminology

Peios / Peios technical reference / PSPU / Name Resolution Interface

Name. A DNS domain name: a sequence of labels. Names compare case-insensitively (RFC 4343). A single-label name has one label; every other name is multi-label. The distinction governs search expansion (§6.7).

Question. A name and a record type (and class, always IN). What a client asks, and the unit the cache is keyed on.

Record. A resource record: name, type, TTL, and data. The protocol is record-level: an answer is records, not addresses shaped for a particular libc structure. lookup (§6.5) is the one convenience that returns addresses, because that is the question every getaddrinfo asks.

Scope. What one interface contributes to resolution: its servers, its search domains, its addresses, whether it claims unmatched names, whether it is exclusive. Scopes come from the network manager (§6.9). The fallback scope is the registry's server list, used only when no interface contributes servers.

Up. A scope is up while its interface's level is link or better (§6.9). Whether it has servers is a separate matter and does not decide it: a scope that is up with no servers still takes every routing step it qualifies for, and a question routed to it is unavailable without a query (§6.7).

Route. The scope a question goes to. Every question goes to exactly one scope (§6.7).

Outcome. How a question ended: found, notfound, or unavailable (§6.6). The three are not interchangeable and no door may collapse two into one.

Source. Where an answer came from: synthetic, hosts, cache, dns, or local (an answer that needed no lookup at all).

Door. A way in: the native socket, the stub listener, or the NSS shim. The doors differ in who can use them and what they can carry; the function behind them does not (§6.3).

Synthetic answer. An answer the resolver gives from what it knows about the machine, before any network: loopback, the machine's own name, static names from the registry, and their reverses.

Control object. The resolver's access-control object, a security descriptor against which every native request is checked (§6.4).

Stub door. DNS itself, on the loopback address 127.0.0.53, for programs that speak DNS directly (§6.8).

6.3 Three Doors, One Function

Peios / Peios technical reference / PSPU / Name Resolution Interface

A resolver MUST offer three doors and MUST answer every question through the same resolution function (§6.7) whichever door it arrived by.

DoorCallersCarries
Native socket (§6.4)Peios tools, the network manager, the NSS shimRecords with TTL, source, validation state; the caller's identity
Stub listener (§6.8)Anything that speaks DNS itself — Go's netgo, musl, hickoryDNS; no caller identity
NSS shim (§6.10)Everything via glibc getaddrinfo and gethostbynamestruct hostent / gaih_addrtuple, rendered from the native door

The rule that matters is the second sentence. A bare printer arriving at the stub door is expanded with the same search domains, routed to the same scope and answered from the same cache as printer arriving on the native socket. A static name from the registry is answered at every door, so a program that never touches libc sees the same printer as one that does. There is no configuration a door reads that another does not, and no file behind any of them.

This is why there is no /etc/hosts: the stub answers static names synthetically, so a direct-DNS resolver gets them without a file. And it is why /etc/resolv.conf is a constant — nameserver 127.0.0.53, options edns0 — shipped by the resolver's package as /usr/etc/resolv.conf, the bottom layer of the /etc merge, and never regenerated: it is a pointer to the answerer, not a copy of the answer. A search list in it would be a second expansion policy the resolver could not see; there is none.

A resolver MUST NOT offer a fourth policy path — an in-process fallback in the shim, a files source, a direct-DNS retry — behind any door. Before the resolver is running, the shim answers localhost itself and nothing else (§6.10).

6.4 The Native Channel

Peios / Peios technical reference / PSPU / Name Resolution Interface

The native door is a SOCK_STREAM Unix socket at a configured path — /run/resolvd/resolv.sock on Peios. Every process MAY connect; what a connection may ask is decided by the control object, not by reaching the socket.

6.4.1 Framing #

Messages in both directions are length-prefixed MessagePack values in the observability query framing (§3.15):

OffsetSizeFieldValue
04lengthLength of the payload in bytes, little-endian
4lengthpayloadA MessagePack map

A request is a map with a required query string naming the request (§6.5). A reply is a map with a required ok boolean; when ok is false it carries error, a string for a person, and nothing else. When ok is true it carries kind — answer, addresses, status — or nothing, for a request with no body to return.

A receiver MUST ignore keys it does not know and MUST reject a message that repeats a key. A resolver MUST refuse a request whose length exceeds the message ceiling (§6.B) without reading the payload, and MUST reply with an error rather than closing silently.

6.4.2 One request per connection #

A connection carries exactly one request and one reply. The client sends, the resolver answers, and either side closes. There is no pipelining and no conversation identifier: the shim that generates most of the traffic is loaded into processes that fork, and a connection held across a fork is how a resolver hands back somebody else's answer. Connecting to a Unix socket is cheap; the cache that would make it cheaper lives in the resolver, where every caller shares it.

A resolver MUST bound how long a connection may take to deliver its request, and MUST drop one that exceeds the bound. A client with a half-sent request holds a few bytes of buffer, never the resolver.

6.4.3 The control object #

Every request names a right (§6.5). Before acting, a resolver MUST perform an access check of that right against its control object using the token of the connecting peer — the peer's real token, as the kernel would judge it, never a numeric credential read from the socket.

The control object's descriptor is Machine\System\Network\Dns ControlSecurity when that value holds a valid self-relative descriptor, else the resolver's compiled default. The default MUST grant RESOLVER_QUERY to Everyone and RESOLVER_ALL_ACCESS to SYSTEM and Administrators.

RightValueGoverns
RESOLVER_QUERY0x0001resolve, lookup, reverse, status
RESOLVER_CONTROL0x0002flush
RESOLVER_ALL_ACCESS0x000F0003Both, with the standard rights

A denied request is answered with an error reply, not a closed connection.

The peer's identity is what identity-aware resolution policy will key on. This version defines no such policy; a resolver MUST NOT invent one.

6.5 Requests

Peios / Peios technical reference / PSPU / Name Resolution Interface

Each request is a map whose query key names it. Field types are the MessagePack types named; an address is a string in its usual textual form; a name is a string in presentation form, with or without a trailing dot.

6.5.1 resolve — RESOLVER_QUERY #

One question.

KeyTypeMeaning
namestringThe name
typeuintThe record type number (1 A, 28 AAAA, 12 PTR, …)
no_cacheboolBypass the cache for this question; default false

Reply kind: answer:

KeyTypeMeaning
outcomestringfound, notfound, unavailable (§6.6)
recordsarray of mapEach: name (string), type (uint), ttl (uint), data (bin, uncompressed wire rdata), text (string, presentation form)
sourcestringsynthetic, hosts, cache, dns, local
serverstring or nilThe upstream that answered, for dns; for cache, the upstream whose answer was cached; nil otherwise
interfacestring or nilThe interface whose scope answered
validationstring§6.6
rcodeuintThe DNS response code, for dns; for cache, the code of the response that was cached; 0 otherwise

records is the answer section only. When the name was expanded (§6.7) the records are at the expanded name, and name on each record says so.

6.5.2 lookup — RESOLVER_QUERY #

The addresses of a name: what getaddrinfo asks. The resolver asks for A and/or AAAA, applies search expansion, follows the CNAME chain and returns what it ends at.

KeyTypeMeaning
namestringThe name
familystringany (default), inet, inet6

Reply kind: addresses:

KeyTypeMeaning
outcomestringfound when at least one family answered; unavailable when none did and one could not; else notfound
canonicalstringThe name the addresses belong to after expansion and CNAME chasing; the name asked, when neither applied
addressesarray of mapEach: address (string), ttl (uint)
sourcestringAs for resolve
validationstring§6.6

6.5.3 reverse — RESOLVER_QUERY #

KeyTypeMeaning
addressstringAn IPv4 or IPv6 address

Equivalent to resolve of the address's reverse-mapping name with type PTR; the reply is kind: answer.

6.5.4 status — RESOLVER_QUERY #

No fields. Reply kind: status:

KeyTypeMeaning
hostnamestringThe machine's name as the resolver knows it
netdboolWhether the network manager channel is connected
scopesarray of mapEach: interface, servers (array of string), domains (array of string), default_route (bool), exclusive (bool), metric (uint), subnets (array of string: the interface's addresses, each as address/prefix, which name the subnets routing step 3 matches against), demoted (array of string)
fallback_serversarray of stringThe registry's server list
cache_entriesuint
countersmapqueries, synthetic, cache_hits, upstream_sent, upstream_answered, upstream_failed, refused, each uint

6.5.5 flush — RESOLVER_CONTROL #

No fields. Drops every cached answer. Reply ok: true with no kind.

6.5.6 Unknown requests #

A resolver MUST answer a query it does not know with an error reply. A client MUST treat an error reply as "not answered", never as notfound.

6.6 Outcomes and Validation

Peios / Peios technical reference / PSPU / Name Resolution Interface

6.6.1 Outcomes #

OutcomeMeaningCached
foundAn answer. Includes a name that exists with no records of the asked type — an empty records with found is NODATA, and is not absence. Yes, by TTL
notfoundThe name does not exist, authoritatively (NXDOMAIN, or a single label with no domain to apply, or .local). Yes, briefly (§6.7)
unavailableNothing that could have answered did: every server timed out, refused, or failed; or no server exists to ask.Never

The third row is the one that carries the weight. A resolver MUST NOT report unavailable as notfound, at any door, and MUST NOT cache it: a source that could have answered and did not is not the same as a name that does not exist, and recording it as one would let an outage be remembered as a fact. Each door renders the distinction in its own terms (§6.8, §6.10).

A resolver MUST answer notfound locally, without a query, for a single-label name with no applicable search domain and for any name under .local (§6.7).

6.6.2 Validation #

Every answer and addresses reply carries validation:

ValueMeaning
unvalidatedThe resolver did not attempt DNSSEC validation.
secureValidated, with a chain of trust to a configured anchor.
insecureValidated as provably unsigned.
bogusValidation failed.

This version of the interface defines no validating resolver, and a resolver that does not validate MUST report unvalidated for every answer from the network. The field exists now so that a client is written against it from the start: when validation arrives, the change is a value, not a field, and a client that wanted only secure answers needs nothing new.

A resolver MUST NOT set the AD bit on a stub-door reply (§6.8) unless its own validation produced secure. An upstream's AD is an assertion by a plaintext peer and MUST NOT be forwarded as one.

6.7 The Resolution Model

Peios / Peios technical reference / PSPU / Name Resolution Interface

This is the function behind every door.

6.7.1 Scopes #

Every interface with a link contributes a scope: servers in the order to try them, search domains, the interface's addresses, whether it claims unmatched names (default_route), whether it is exclusive, and its metric. Scopes arrive from the network manager (§6.9); the resolver holds them and nothing else about the network. The fallback scope is Machine\System\Network\Dns FallbackServers and ExtraSearchDomains.

6.7.2 Routing #

A question goes to exactly one scope. A resolver MUST NOT send a question to several scopes' servers and take the first answer — parallel fan-out is the mechanism of the classic VPN name leak, and it also lets a hostile network win a race for a name it has no claim to.

The order:

  1. An exclusive scope, if any interface at addressed or better is exclusive: it takes every question. Several exclusive scopes: the lowest metric. While an exclusive interface is up no other interface's servers are consulted for anything.
  2. The longest matching search domain. The scope with the longest domain the name ends with; ties by metric.
  3. The subnet, for a reverse-mapping name: the scope whose interface has an address in the same subnet as the address named.
  4. The default-route claimant with the lowest metric.
  5. The lowest-metric scope with servers, when no interface claims the default route.
  6. The fallback scope, only when no interface contributes servers.

When none of these yields a scope with servers, the question is unavailable without a query.

This is DNS routing only. Which interface a packet leaves by is the network manager's and the firewall's.

6.7.3 Search expansion #

A single-label name is never sent upstream as it is. It is expanded with the applicable search domains, in order, and each candidate is asked in turn until one is not notfound. The applicable domains are the exclusive scope's when one is up; otherwise every up scope's, in metric order, followed by the fallback scope's. With no applicable domain the name is notfound without a query.

A multi-label name is never expanded. There is no ndots setting: the rule is fixed at one, because every other value is a leak of local names to the public tree or of public names to the local one.

An answer at an expanded name reports the expanded name (resolved records on the native channel; a CNAME from the bare name at the stub door, §6.8).

6.7.4 Synthetic answers #

Before any network, a resolver MUST answer:

  • localhost and any name under it: 127.0.0.1 and ::1;
  • the machine's hostname: every unicast address of every interface at addressed or better, or loopback when there are none;
  • every name under Machine\System\Network\Dns\Hosts, exactly and case-insensitively: its addresses. A static name wins over DNS;
  • the reverse of loopback (localhost), of a static name's address (the name whose ASCII-lowercased presentation form sorts first in byte order when several names share it), and of the machine's own addresses (the hostname);
  • any name under .local: notfound. mDNS is a separate transport (RFC 6762) and a .local question MUST NOT reach a DNS server. LLMNR MUST NOT be spoken.

A synthetic answer has source synthetic (or hosts) and TTL 0.

6.7.5 The cache #

The cache is keyed by (name, type, scope), the name folded to lower case. The scope is part of the key because the same name may have different answers on different interfaces (split horizon), and because when an interface's servers change or the interface goes away, every answer learned through it MUST be discarded: what a VPN's servers said dies with the VPN.

Positive answers live for their least TTL, capped (§6.B). Negative answers — notfound, and found with no records — live for the lesser of the authority section's SOA record TTL and its MINIMUM field (RFC 2308 §5), capped lower. unavailable is never cached. A hit reports TTLs reduced by the time spent in cache.

6.7.6 Upstream behaviour #

A resolver MUST send each query from a fresh source port, MUST use a random message identifier, and MUST randomise the case of the question name and require the reply to echo it exactly (0x20 encoding). Both the identifier and the case bits MUST come from a cryptographically secure generator: a predictable one hands a spoofer back the entropy these measures exist to add. A reply whose identifier, question or case pattern does not match MUST be ignored, not treated as a failure.

Queries are UDP with EDNS0 advertising the buffer size in §6.B; a truncated reply is retried over TCP to the same server. A server that does not answer within the per-server timeout, or answers SERVFAIL, REFUSED or a format error, is demoted — tried after its peers — for the demotion period; the question moves to the next server. After the attempt limit the question is unavailable. Servers within a scope are tried in the scope's order, healthy ones first; never in parallel.

6.8 The Stub Door

Peios / Peios technical reference / PSPU / Name Resolution Interface

A resolver MUST listen for DNS over UDP and TCP on 127.0.0.53 port 53 and MUST NOT listen on any other address: not ::1, which a local DNS server may legitimately want, and never a routable one. It MUST ignore a datagram or connection whose source is not a loopback address.

The stub door exists for software that does not go through libc — a Go binary built with netgo, a musl program, a Rust resolver library — and it is non-optional: the constant /etc/resolv.conf points at it. It is also anonymous: DNS carries no caller identity, and the door is not governed by the control object. Everything on the machine may ask.

6.8.1 Rendering #

A query is answered by the same function as every other door (§6.7), including search expansion of a single-label question. The reply is rendered as:

OutcomeReply
foundNOERROR, the records in the answer section
notfoundNXDOMAIN
unavailableSERVFAIL

When the answer is at an expanded name, the reply's answer section MUST begin with a CNAME from the question name to the expanded name, so that a client checking names sees a well-formed chain. RA is set; AA is not; AD is not, per §6.6.

A query with an opcode other than QUERY is answered NOTIMP; one with other than exactly one question, or that cannot be decoded, is answered FORMERR when there is enough of a header to echo. A reply larger than the client's advertised buffer (or 512 bytes without EDNS) is truncated with TC set; TCP carries the whole answer.

6.8.2 Binding the port #

Port 53 is below the privileged floor. On Peios the resolver's service SID holds a port reservation for tcp,udp:53, shipped as a registry seed by the resolver's package; the resolver needs no privilege to bind it. A resolver MUST NOT require SeTcbPrivilege or any capability for its listeners.

6.9 The Network Manager Channel

Peios / Peios technical reference / PSPU / Name Resolution Interface

The live facts of the network — which interface has which servers, which carries the default route — never reach the resolver through the registry, whatever the manager also records there. They go from the network manager to the resolver directly, over the manager's own control socket, and they are gone when the manager is.

6.9.1 subscribe #

The resolver connects to the manager's control socket (/run/netd/control.sock on Peios; framing as §6.4) and sends {"query": "subscribe"}, which requires the manager's query right (NETWORK_QUERY). The manager replies at once with a snapshot and then keeps the connection open, sending a fresh snapshot every time the picture changes, until either side closes. This is the one request on that socket that holds a connection.

A snapshot is ok: true, kind: snapshot:

KeyTypeMeaning
hostnamestringThe machine's name as set by the manager; empty when unset
scopesarray of mapOne per managed interface with a link, in metric order

Each scope:

KeyTypeMeaning
ifidstringThe interface's stable identity; the cache scope key
namestringThe interface name
serversarray of stringThe profile's static servers, then the lease's when the profile takes them
domainsarray of stringThe profile's search domains, then the lease's search list or domain
addressesarray of stringEvery unicast address, CIDR form
default_routeboolThe profile's Dns.Default; unset, whether the interface carries a default route
exclusiveboolThe profile's Dns.Exclusive
metricuintThe route metric
levelstringlink, addressed, routed

A snapshot is the whole picture, not a delta. It is a few hundred bytes, and a whole picture cannot be misapplied out of order. The resolver MUST replace its scopes with each snapshot; a scope whose servers differ from before loses its cached answers (§6.7).

6.9.2 Failure and reconnection #

The manager MAY drop a subscriber it cannot write to. The resolver MUST reconnect with backoff whenever the connection is lost or the manager is absent, and MUST keep answering meanwhile: synthetic names need no scope, and the scopes it holds remain valid until replaced. A resolver MUST NOT treat the manager's absence as a reason to refuse questions.

The manager merges the profile's static DNS keys with the lease itself; the resolver reads Profiles\ for nothing. The registry keys the resolver reads are exactly Machine\System\Network\Dns and its subkeys; it MAY watch the parent Machine\System\Network so that the key's creation is seen. When the manager reports no hostname the resolver uses the kernel's.

6.10 The Hosts Shim

Peios / Peios technical reference / PSPU / Name Resolution Interface

The shim is the NSS module glibc's hosts database reaches, and on Peios the only one it can reach: glibc is patched so that hosts, like the identity databases, is not configurable and names peios_net whatever nsswitch.conf says. There is no files source behind it and no /etc/hosts for one to read.

6.10.1 Obligations #

A shim MUST:

  1. Forward every question to the native channel (§6.4) as lookup or reverse, one connection per libc call, and render the reply. It MUST NOT hold a connection across calls.
  2. Answer localhost, and any name under it, and the reverse of a loopback address, itself — with no socket. It is the one name a machine can never be without, and the only one the shim knows.
  3. Answer NSS_STATUS_UNAVAIL for everything else when the resolver cannot be reached. There is nothing behind it.
  4. Report an over-full caller buffer as NSS_STATUS_TRYAGAIN, with *errnop = ERANGE and *h_errnop = NETDB_INTERNAL (-1), so glibc retries with a larger one.

A shim MUST NOT read /etc/hosts, /etc/resolv.conf or any file; MUST NOT speak DNS itself; and MUST NOT cache. Each would be a second policy path the resolver cannot see.

The complete buffer-exhaustion triple is required by the glibc NSS interface: NETDB_INTERNAL tells the caller to examine errnop, including its ERANGE buffer-size indication.

6.10.2 Status mapping #

Resolver outcomeNSS statush_errno
found, with addresses of the asked familySUCCESS—
found, none of that familyNOTFOUNDNO_DATA
notfoundNOTFOUNDHOST_NOT_FOUND
unavailableTRYAGAIN (EAGAIN)TRY_AGAIN
Resolver unreachable, or an error replyUNAVAILNO_RECOVERY

The unavailable row is the one that matters: a shim MUST NOT render it as NOTFOUND. A caller that retries gets its answer when the network comes back; a caller told "no such host" may remember that.

Note

glibc's getaddrinfo reports a module's UNAVAIL to its caller as EAI_NONAME, the same code as a name that does not exist, so through that interface an unreachable resolver still looks like an absent name. That mapping is glibc's; the shim reports UNAVAIL faithfully, and the native channel and resolv keep the distinction.

h_name is the reply's canonical name. TTLs are reported through ttlp where glibc offers it, as the least TTL among the addresses.

6.10.3 Entry points #

_nss_peios_net_gethostbyname4_r, gethostbyname3_r, gethostbyname2_r, gethostbyname_r, gethostbyaddr2_r, gethostbyaddr_r. The module's SONAME is libnss_peios_net.so.2 and it is installed in glibc's configured library directory; it links against libc and the wire codec and nothing else, since it is loaded into every process that resolves a host.

6.11 Conformance

Peios / Peios technical reference / PSPU / Name Resolution Interface

A conforming implementation MUST satisfy every requirement in this chapter. This section collects them by role.

6.11.1 Resolver obligations #

6.11.1.1 Doors #

  1. Offer the native socket, the stub listener and answer the shim, and run one resolution function behind all three (§6.3).
  2. Offer no other policy path: no files, no in-shim DNS, no fallback (§6.3).

6.11.1.2 Native channel #

  1. Frame as §6.4; refuse an oversized request without reading it, with an error reply (§6.4).
  2. Ignore unknown keys; reject duplicate keys (§6.4).
  3. One request per connection; bound the time a request may take to arrive (§6.4).
  4. Access-check every request against the control object with the peer's real token; default Everyone RESOLVER_QUERY, SYSTEM and Administrators all (§6.4).
  5. Answer unknown requests and denied requests with error replies (§6.4, §6.5).

6.11.1.3 Outcomes #

  1. Never report unavailable as notfound; never cache unavailable (§6.6).
  2. Report unvalidated until validating; never forward an upstream's AD (§6.6).

6.11.1.4 Resolution #

  1. Route every question to exactly one scope, in the order of §6.7; never fan out.
  2. Honour an exclusive scope absolutely while it is up (§6.7).
  3. Expand single labels with applicable domains only; never send one bare; never expand a multi-label name; no ndots (§6.7).
  4. Answer the synthetic names of §6.7 before any network; never forward .local; never speak LLMNR.
  5. Key the cache by scope; flush a scope whose servers change or that goes away; cap TTLs as §6.B (§6.7).
  6. Fresh source port, random identifier, 0x20 case with exact echo; ignore mismatched replies (§6.7).
  7. EDNS0 with the §6.B buffer; TCP on truncation; demote failing servers; bounded attempts (§6.7).

6.11.1.5 Stub door #

  1. Listen on 127.0.0.53 only, UDP and TCP; ignore non-loopback sources (§6.8).
  2. Render outcomes as §6.8; CNAME from a bare question to its expanded name; NOTIMP and FORMERR as §6.8; truncate over the buffer.
  3. Bind without privilege (§6.8).

6.11.1.6 Network manager channel #

  1. Subscribe; replace scopes on every snapshot; reconnect with backoff; keep answering while disconnected (§6.9).
  2. Read only Machine\System\Network\Dns from the registry (§6.9).

6.11.2 Network manager obligations #

  1. Answer subscribe with a snapshot and stream one on every change on the same connection (§6.9).
  2. Send whole snapshots, never deltas; merge profile and lease facts itself (§6.9).

6.11.3 Client obligations #

  1. Treat an error reply as "not answered", never as notfound (§6.5).
  2. Be written against validation (§6.6).

6.11.4 Shim obligations #

  1. One connection per libc call; answer localhost alone; UNAVAIL when the resolver is unreachable; ERANGE retry (§6.10).
  2. No files, no DNS, no cache (§6.10).
  3. Map outcomes as §6.10; never render unavailable as NOTFOUND.

Appendix 6.A Message Reference

Peios / Peios technical reference / PSPU / Name Resolution Interface

6.A.1 Native channel requests #

queryRightReply kindDefined in
resolveRESOLVER_QUERYanswer§6.5
lookupRESOLVER_QUERYaddresses§6.5
reverseRESOLVER_QUERYanswer§6.5
statusRESOLVER_QUERYstatus§6.5
flushRESOLVER_CONTROL(none)§6.5

6.A.2 Network manager channel #

queryRightReply kindDefined in
subscribeNETWORK_QUERYsnapshot, streamed§6.9

6.A.3 Rights #

ConstantValueDefined in
RESOLVER_QUERY0x00000001§6.4
RESOLVER_CONTROL0x00000002§6.4
RESOLVER_ALL_ACCESS0x000F0003§6.4

6.A.4 Enumerations #

FieldValuesDefined in
outcomefound, notfound, unavailable§6.6
validationunvalidated, secure, insecure, bogus§6.6
sourcelocal, synthetic, hosts, cache, dns§6.2
familyany, inet, inet6§6.5
levellink, addressed, routed; never absent in a snapshot, since an absent interface contributes no scope§6.9

6.A.5 Paths and addresses (Peios) #

ItemValue
Native socket/run/resolvd/resolv.sock
Stub listener127.0.0.53:53, UDP and TCP
Network manager socket/run/netd/control.sock
Registry keyMachine\System\Network\Dns
Shimlibnss_peios_net.so.2
Compat file/usr/etc/resolv.conf, constant

Appendix 6.B Limits

Peios / Peios technical reference / PSPU / Name Resolution Interface

BoundMainlineDefined in
Message ceiling, native channel65 536 bytes §6.4
Request delivery bound, native channel5 s §6.4
Per-server timeout2 s §6.7
Attempts per question3 §6.7
Demotion period30 s §6.7
Positive TTL cap86 400 s §6.7
Negative TTL cap300 s §6.7
EDNS0 buffer advertised1 232 bytes §6.7
Upstream transactions in flight (synthetic and cached answers are never refused)4 096 §6.7
Native connections delivering a request or awaiting resolution256 §6.4
Stub TCP clients delivering a query or awaiting resolution256 §6.8
Stub UDP queries awaiting resolution256 total; 16 per source address and port §6.8
Cache entries8 192 §6.7
Stub TCP client bound10 s §6.8
Manager reconnect backoff0.5 s doubling to 10 s §6.9

The upstream ceiling leaves synthetic and cached answers unaffected once a query has passed its door's admission limits. Native and stub TCP reply writers have separate 256-frame queues and one-second write deadlines.

An implementation MAY adjust these; it MUST document the values it uses. The relations that must hold: the shim's own timeout exceeds attempts × per-server timeout, so a slow upstream is reported by the resolver as unavailable rather than by the shim as a timeout.

7.1 Scope and Roles

Peios / Peios technical reference / PSPU / Job Submission

This chapter defines the jobs channel: the interface by which a process asks a Peios service manager to run an arbitrary program under its supervision, with an identity the requester is entitled to confer, and to manage that program afterwards.

A job submitted this way is a job in the sense of §4.2 — one process execution, supervised by the manager — whose parent is the process that asked for it rather than a service definition. The manager forks it, installs its token, contains it, records it and reaps it exactly as it does a service's process. What differs is where the definition came from and who is entitled to manage the result.

Two shapes of use are intended, and the chapter is written so that one primitive serves both:

  • A task: a program that runs to completion and reports progress on the way — a backup, a transfer, a migration — submitted by a broker on behalf of the principal it is acting for.
  • A session: a long-lived program managed from outside — a per-logon host process for an interactive session — submitted with a freshly issued logon token and one end of a channel back to its submitter.

Nothing in the protocol distinguishes the two. They are the same message with different field values.

7.1.1 The roles #

The manager is the process that supervises services and listens on the control and notification channels of §4. It listens on the jobs channel too. On Peios this is peinit.

A submitter connects to the jobs channel, submits a job, and MAY manage the jobs it submitted. A submitter is any process the socket's descriptor admits; it holds no relationship with the manager beyond the one its token establishes.

A job is the process the manager started on a submitter's behalf. It speaks the notification channel of §4 about itself, in the same way a service does, and this chapter adds nothing to that role beyond the progress fields defined in §4.19.

A client of the control channel (§4) MAY observe and stop jobs it is entitled to, alongside services. That role is unchanged here; §4.14 defines the commands.

Requirements are stated against the role, not the program. A service that submits jobs is a submitter when it does so; a job that submits jobs of its own is a submitter too.

7.1.2 What this chapter covers #

  • the jobs socket, how it is reached, and who may reach it
  • message framing on it, and how a message carries a token and descriptors alongside its content
  • how the identity a job runs as is established, and the rule that the manager never installs an identity the submitter could not already act as
  • the job definition a submitter sends, and what the manager validates
  • the job view the manager reports, its states and its causes
  • the commands a submitter may issue about its own jobs, and the Security Descriptor that decides who else may
  • passing descriptors into a job, and receiving its output
  • the error vocabulary, the extension rules and the conformance requirements

7.1.3 What this chapter does not cover #

  • The notification channel, including the progress fields a job sends. A job reports on itself exactly as a service does. §4.16–§4.19.
  • Observing and stopping jobs from the control channel. The job-list, job-status and job-stop commands are part of the control channel's command set and are defined in §4.14, using the job view this chapter defines.
  • How the manager supervises the job once it is running — cgroup containment, output capture, reaping, the child setup sequence. That is the manager's own design, documented in its reference manual.
  • How a token is conveyed on a socket, and what the kernel checks when it is. Peer-token capture, KACS_SCM_TOKEN, the impersonation gates and the impersonation-level ratchet are kernel operations, documented in the Peios Kernel TRM §3.5.
  • How a submitter obtained the token it attaches. From a peer it is serving, from a logon it originated (PGSS §2), or from anywhere else — the manager does not ask, because the kernel has already decided whether the submitter may convey it.

7.2 Terminology

Peios / Peios technical reference / PSPU / Job Submission

Terms defined in §4.2 — service, job, activation generation, right, terminal state, datagram — are used here with the same meaning and are not redefined.

Submitted job. A job whose definition arrived on the jobs channel rather than from a service definition. It has no service, no operation, and no activation generation; it belongs to a submitter.

Submitter. The principal that submitted a job: the identity the connection carried when the submit message was read. It is recorded on the job and is the owner of the job's Security Descriptor.

Job identity. The identity a submitted job's process runs as. It is either the submitter's own primary identity or one the submitter attached to the submit message; it is never named by a field.

Attached token. A token conveyed with a message through the kernel's per-message identity facility (KACS_SCM_TOKEN; the Peios Kernel TRM §3.5). The kernel has verified, at the moment of sending, that the sender could act as that identity at the level recorded on the token.

Attached descriptor. A file descriptor conveyed with a message through SCM_RIGHTS, to be given to the job or used as its output sink.

Message. One SOCK_SEQPACKET record on the jobs channel, carrying exactly one JSON object and, optionally, one attached token and any number of attached descriptors. The jobs channel has no frames in the sense of §4.2: the transport delimits messages.

Job view. The JSON object by which the manager reports a job, on either channel. §7.7.

Live job. A submitted job in a non-terminal state. Quotas count live jobs.

7.3 The Jobs Socket

Peios / Peios technical reference / PSPU / Job Submission

The manager MUST listen on a Unix SOCK_SEQPACKET socket at a well-known path. On Peios that path is:

/run/services/peinit/jobs.sock

The socket MUST exist for as long as the manager is serving, and the manager MUST unlink it when it stops. The manager MUST create the listening socket and every accepted connection with close-on-exec set, so that no connection descriptor is inherited by a job it starts.

7.3.1 Why a second socket #

The control channel (§4.4) is an administrator's door: its commands change the state of the system's services, and the descriptor on its socket admits the principals entitled to do that. Submitting a job is a different permission held by a different population. Any principal may reasonably be allowed to run a program under supervision, subject to a quota, without being allowed to stop registryd.

Putting the two on one socket would force one descriptor to serve both populations. Two sockets let the filesystem say who may submit and who may administer, separately, with no policy inside the manager.

7.3.2 Why a sequenced-packet socket #

A message on this channel carries more than bytes. It carries the token a job is to run as, and the descriptors a job is to be given, as ancillary data. The kernel associates ancillary data with the record it was sent with, and on a stream that association is only as precise as the reader's record boundaries. SOCK_SEQPACKET makes one send one message and one identity (the Peios Kernel TRM §3.5), so the manager never has to decide which request an attached token or descriptor belongs to.

The cost is that a message has a maximum size (§7.A) and cannot be split. A definition is small; nothing here needs to be large.

7.3.3 Who may submit #

Being able to connect is the submission permission. The manager MUST NOT perform an access check of its own before accepting a submit; the check is the one the kernel performs against the socket's inode descriptor when the submitter connects, and the manager relies on it entirely.

The manager MUST therefore ensure the socket, and each directory containing it, carries a Security Descriptor admitting exactly the principals it intends to let submit. The default a Peios service manager applies is in §7.A. §4.3's warning applies with full force: a socket that inherits nothing is reachable by nobody, and one in a permissive place is reachable by anything.

What a submitter may do to a job is a separate question, answered by the job's own descriptor (§7.8). Reaching the socket confers the right to submit, and nothing else.

7.3.4 A connection #

A submitter connects, issues one or more messages, and closes. The manager MUST NOT require a submitter to issue any message before another, and MUST NOT hold state across connections beyond the jobs themselves: a connection carries an identity (§7.5) and nothing else.

Messages on one connection MUST be answered in the order they were received. The manager MAY read no further messages from a connection while a response on it is outstanding.

Closing a connection MUST NOT affect any job submitted on it. A job belongs to its submitter's identity, not to the connection that submitted it; a submitter that reconnects — after a restart, say — finds its jobs where it left them.

7.3.5 Limits #

The manager MUST enforce three limits on the channel and one on the submitter, and MUST make their values discoverable to an administrator through the same surface that sets them. The values a Peios service manager uses by default are in §7.A.

Concurrent connections. A connection accepted while the manager is already at its limit MUST be closed at the socket level, without a response, and a submitter MUST treat an immediate close with no response as a refusal rather than a protocol error.

Message size. A message whose content exceeds the limit MUST be answered with REQUEST_TOO_LARGE. The manager MUST detect a message the kernel truncated to fit its receive buffer (MSG_TRUNC) and MUST NOT interpret the truncated content: a truncated JSON object is malformed by construction, but a truncated attachment list is not, and the manager MUST NOT act on a request whose attachments it did not fully receive.

Idle timeout. A connection with no message outstanding MAY be closed once it has been idle for the configured period, without a response. A connection blocked on a wait (§7.8), or on a submit whose job has not yet confirmed exec, is not idle and MUST NOT be closed for idleness.

Live jobs per submitter. The manager MUST bound the number of live jobs one submitter may hold, counted by the submitter's user SID, and MUST answer a submit that would exceed the bound with QUOTA_EXCEEDED, creating nothing. The manager MAY exempt the SYSTEM principal (S-1-5-18). A job that reaches a terminal state stops counting at once, not when its record is dropped (§7.7).

7.4 Messages

Peios / Peios technical reference / PSPU / Job Submission

7.4.1 Content #

Every message in both directions carries exactly one JSON object, serialised compactly, as the whole of the record's content. There is no terminator: the transport delimits messages, and the manager MUST NOT require or emit a trailing newline.

Content is UTF-8. The manager MUST reject content that is not well-formed UTF-8 with MALFORMED_REQUEST.

The rules of §4.5 for what is malformed apply unchanged: an empty message, content that is not valid JSON, and valid JSON that is not an object are all MALFORMED_REQUEST. The manager MUST NOT emit pretty-printed JSON. Timestamps follow §4.5.

7.4.2 Attachments #

A request MAY carry ancillary data. Two kinds are defined:

LevelTypeMeaningApplies to
SOL_KACSKACS_SCM_TOKENThe token the job is to run as. At most one.submit
SOL_SOCKETSCM_RIGHTSDescriptors for the job, or an output sink.submit

The manager MUST receive every message with room for both kinds and for the number of descriptors in §7.A. It MUST detect ancillary data the kernel could not fit (MSG_CTRUNC) and MUST answer the message INVALID_ARGUMENTS without acting on it: a request whose attachments were partly lost does not describe the job the submitter meant.

Attachments on a message whose command does not use them MUST be closed and ignored. A stray descriptor on a status is not an error, in the same spirit as §4.8's rule for fields that do not apply — but it is closed, because nothing else could own it.

The manager MUST close every attached descriptor it does not hand to a job or adopt as an output sink, on every path including error paths. A submitter MUST assume the manager has taken ownership of everything it attached to a submit, whatever the outcome, and MUST NOT rely on the manager leaving a descriptor open.

A response MAY carry ancillary data. One kind is defined:

LevelTypeMeaningCarried by
SOL_SOCKETSCM_RIGHTSThe job's process handle. Exactly one.A submit response for a running job. §7.6

A submitter that receives a response without room for its ancillary data does not receive the handle, and MUST NOT treat that as an error: the response content is complete without it.

7.4.3 Requests #

A request is one JSON object.

{"command": "submit", "image_path": "/usr/bin/peios-backup",
 "arguments": ["sync", "/home/u/docs"], "description": "Backup of docs"}
FieldTypeRequiredMeaning
commandstringalwayssubmit, status, wait, stop or signal.
job_idstringall but submitThe job.
waitboolnoFor stop: block until the job is terminal. §7.8
forstringnoFor wait: ready or terminal. §7.8
signalintegerfor signalThe signal number. §7.8

The submit definition fields are in §7.6.

command MUST be present and MUST be a string naming one of the five commands. Otherwise the manager MUST answer INVALID_COMMAND.

job_id MUST be present and a string for every command but submit. Its absence, a non-string value, or a value that is not a well-formed identifier MUST be answered INVALID_ARGUMENTS.

A field the command does not use MUST be ignored, not rejected. A field the manager does not recognise MUST be ignored (§7.11).

7.4.4 Responses #

Every response carries a status field, exactly "ok" or "error".

The success shape for every command is the job view (§7.7):

{"status": "ok", "job": { … }}

The error shape is §4.9's:

{"status": "error", "code": "QUOTA_EXCEEDED",
 "message": "submitter S-1-5-21-… holds 64 live jobs"}

code MUST be one of the values in §7.10. message is not normative; a submitter MUST NOT parse or branch on it.

7.4.5 Closing after an error #

§4.5's distinction applies. On a sequenced-packet socket a frame-level failure is narrower — the transport cannot lose synchronisation — so only REQUEST_TOO_LARGE and a truncated message close the connection. Every other error is command-level: the manager MUST send the error and keep the connection open.

A submitter MUST NOT assume a connection survives an error response.

7.5 Identity

Peios / Peios technical reference / PSPU / Job Submission

Every submission involves two identities, and the protocol keeps them apart.

The submitter is who asked. The job identity is who the job runs as. They are the same principal in the simplest case and different ones in the case the channel exists for — a broker running a program as the client it is serving.

7.5.1 The submitter #

The manager MUST establish the submitter's identity from the kernel, as §4.6 requires of the control channel: the peer's token, captured when the connection was accepted, used for every message on the connection. A submitter MUST NOT be able to assert who it is, and a change of identity mid-connection has no effect on it.

The submitter's identity is what the manager records on the job as its submitter, what it counts quotas against, and what it names as the owner of the job's descriptor (§7.8). It MUST be the identity the peer thread was acting under when it connected — so a submitter that connects while impersonating is recorded as the principal it was impersonating. A submitter that intends to own the jobs it submits, as itself, connects as itself and conveys any other identity per message.

7.5.2 The job identity #

The job identity is established in exactly one of two ways, and the manager MUST NOT provide a third.

No token attached — the submitter's own primary. The manager MUST open the primary token of the connecting process — identified by the kernel through the peer's process handle (SO_PEERPIDFD), never by a PID the submitter names — duplicate it, and install the duplicate on the job.

The job then has the identity a child of the submitter would have had: its primary token, with no impersonation inherited (the Peios Kernel TRM §3.2). A submitter that was impersonating when it connected therefore produces a job running as its own identity, not the impersonated one, while being recorded as a submitter under the impersonated one. The two differ deliberately, and a submitter that wants the impersonated identity on the job attaches it.

A token attached — the identity conveyed with the message. The manager MUST use the token the kernel delivered with the submit message, duplicate it to a primary token, and install the duplicate on the job.

The kernel gated the attach: it ran the impersonation gates with the submitter as installer and recorded on the token the lower of the socket's level and the source's own (the Peios Kernel TRM §3.5). The attached token is therefore a kernel-verified statement that the submitter could have acted as this identity, at this level, when it sent this message. The manager MUST rely on that statement and MUST NOT re-derive it.

7.5.3 What the manager checks #

The manager MUST refuse, with BAD_TOKEN:

  • more than one attached token on a message;
  • an attached token whose impersonation level is below Impersonation. An Identification-level token cannot pass an access check and an Anonymous one is not an identity to run a process as; neither can become a primary (the Peios Kernel TRM §3.2.4);
  • an attached token the manager cannot duplicate.

The manager MUST NOT refuse a token because of who it names. Whether the submitter may run a job as that identity was decided by the kernel when the token was conveyed, and a second decision inside the manager would be a second copy of the same policy, able only to disagree with the first.

7.5.4 The level carries through #

The duplicated primary carries the attached token's impersonation level, and everything the job does inherits it (the Peios Kernel TRM §3.5.1). A submitter that needs the job to convey Delegation — to a session that will authenticate to remote services on its user's behalf — MUST set its own jobs connection to Delegation before attaching, because the default level on a socket is Impersonation and the attach is clamped to it. A job started at Impersonation cannot be raised afterwards, by anyone.

7.5.5 What the manager never does #

The manager MUST NOT accept a primary token passed as a plain descriptor (SCM_RIGHTS) as a job identity. A descriptor passed that way is not gated by the kernel: whoever holds an fd can pass it, and the recipient learns nothing about whether the sender could act as the identity it names. Every job identity on this channel is one the kernel verified the submitter could act as, and a path that skips the verification would be the only one worth attacking.

The manager MUST NOT install on a job any token it obtained by a route other than the two above. In particular it MUST NOT open a token through a submitter-supplied PID or descriptor, and MUST NOT let a submit field name an identity. There is no identity field, and a manager MUST NOT add one (§7.11).

Note

The consequence for a service that runs programs as its clients is that it needs SeImpersonatePrivilege — to be allowed to convey a client's identity at all — and nothing more. It never holds a primary token for the client, never needs SeAssignPrimaryTokenPrivilege, and never installs anything. The one process that installs primary tokens on other people's behalf is the one that supervises the result.

7.6 Submitting a Job

Peios / Peios technical reference / PSPU / Job Submission

A submit carries the whole definition of the job in the request object. There is no persistent definition, no name, and no policy: a submitted job runs once, as described, and is then gone.

7.6.1 The definition #

FieldTypeRequiredDefaultMeaning
image_pathstringyes—The program to execute. An absolute path.
argumentsarray of stringsno[]Its arguments, after the program name.
environmentobject of string → stringno{}Variables to set. §7.6 below.
working_directorystringno/The working directory. An absolute path.
descriptionstringno""A human-readable label, for views and records.
timeoutintegerno0Seconds the job may run before it is stopped. 0 is no limit.
stop_timeoutintegerno§7.ASeconds between the termination signal and the kill.
readinessstringnononenone, or notify to wait for READY=1.
readiness_timeoutintegerno§7.ASeconds a notify job has to become ready.
success_exit_codesarray of integersno[]Non-zero exit codes that count as success.
descriptorsarray of stringsno[]Names for the descriptors to inject. §7.9
outputboolnofalseWhether the last attached descriptor is an output sink. §7.9
security_descriptorstringno§7.8The job's descriptor, in SDDL. §7.8

Nothing here restarts, depends, probes, or schedules. A program that needs a restart policy, dependencies, a health check or a trigger is a service, and its definition belongs in the registry (§4.1). The manager MUST NOT accept a policy field on a submit, and MUST NOT add one (§7.11).

7.6.1.1 Validation #

The manager MUST answer INVALID_ARGUMENTS, creating nothing, when:

  • image_path is absent, not a string, empty, not absolute, or contains a NUL byte;
  • working_directory is present and not an absolute string, or contains a NUL byte;
  • any element of arguments is not a string or contains a NUL byte;
  • any key of environment is empty, contains =, or contains a NUL byte; or any value contains a NUL byte;
  • any of timeout, stop_timeout, readiness_timeout is present and not a non-negative integer, or stop_timeout is 0;
  • readiness is present and not one of the two values;
  • any element of success_exit_codes is not an integer in 0–255;
  • descriptors does not match the attached descriptors (§7.9);
  • security_descriptor is present and is not a valid descriptor with an owner and a DACL (§7.8);
  • the total size of the resulting argv and environment exceeds what the manager can pass to execve.

The manager MUST NOT validate that image_path exists or is executable. That is decided at execve, by the job's own token against the file's descriptor, and a job whose program cannot be run becomes a failed job (§7.7) rather than a refused submission. Checking in advance would either check with the wrong identity or duplicate the kernel's decision.

7.6.1.2 The environment #

The manager MUST build the job's environment as it builds a service's, with environment in the place a service definition's own variables occupy: above any manager-wide layer an administrator configures, and below the protocol variables the manager sets last. A submitter MUST NOT be able to override NOTIFY_SOCKET, LISTEN_FDS, LISTEN_FDNAMES or LISTEN_PID.

The manager sets no HOME, USER, LOGNAME or SHELL — it knows nothing about the job identity beyond its token, and a submitter that knows the principal's home directory supplies it. A submitter that originated a logon has these from the logon's profile (PGSS §2.9).

7.6.2 What the manager does #

On an accepted submit the manager MUST, in order:

  1. Establish the job identity (§7.5) and refuse with BAD_TOKEN if it cannot.
  2. Check the submitter's quota and refuse with QUOTA_EXCEEDED if the job would exceed it.
  3. Create the job record, with its identifier, in the created state, owned by the submitter with the descriptor of §7.8.
  4. Start the process — install the identity, set up its streams, inject descriptors, execute — as it starts a service's main process.
  5. Answer the submit.

Steps 1 and 2 MUST both precede step 3: a refused submission MUST leave no record, emit no lifecycle event, and consume no quota.

7.6.3 When the submit is answered #

The manager MUST answer a submit when the job has left the created state: when the process has confirmed a successful execve, or when starting it has failed.

It MUST NOT answer earlier. A submitter holding a response holds a job that either exists as a running process or has definitively failed to — never one whose fate is still being decided in the setup between fork and exec.

It MUST NOT answer later. In particular a readiness: notify job is answered when it is running, not when it is ready; a submitter that wants to block until readiness follows with a wait (§7.8).

7.6.4 The response #

The response is the job view (§7.7).

If the job is running, the response MUST carry the job's process handle — a pidfd referring to the job's main process — as an attached descriptor. The handle lets the submitter observe the process's exit with poll or waitid, and signal it, with no further protocol; the kernel applies its ordinary process access rules to whatever the submitter does with it. A submitter that declined to receive ancillary data simply does not get the handle (§7.4).

If the job is failed, the response carries no descriptor, and the view's cause says why (§7.7). The submission itself succeeded — a job was created, recorded and reported — so the response is "status": "ok". Only a submission the manager refused outright, at steps 1–3, is an error response.

Note

A submitter that needs the process handle as a child would have — to waitpid on it, or to be its parent for PR_SET_PDEATHSIG — does not get that, because the job is the manager's child, not the submitter's. Exit status reaches the submitter through the job view, through wait, or through the manager's event stream; the pidfd is for polling, signalling, and passing to something else.

7.7 The Job View

Peios / Peios technical reference / PSPU / Job Submission

The job view is the shape every successful response on this channel carries, and the shape job-status, job-list and job-stop return on the control channel (§4.14), which adds one field to it. It is defined once, here.

{
    "id": "0190a3b2-…",
    "type": "submitted",
    "state": "running",
    "cause": null,
    "submitter": "S-1-5-80-…",
    "identity": "S-1-5-21-…",
    "logon_session": 1042,
    "description": "Backup of docs",
    "image_path": "/usr/bin/peios-backup",
    "pid": 4521,
    "ready": null,
    "exit_code": null,
    "exit_signal": null,
    "status_text": "Backing up /home/u/docs (3/5)",
    "progress": {"current": 812, "total": 2048, "bounded": true,
                 "unit": "items"},
    "created_at": "2026-06-01T12:34:56.123456789Z",
    "started_at": "2026-06-01T12:34:56.223456789Z",
    "ended_at": null
}
FieldTypeMeaning
idstringThe job's identifier.
typestringAlways submitted on this channel. §4.B
statestring§7.B.
causestring or nullWhy the job ended, where the manager decided it. §7.B
submitterstringThe submitter's user SID.
identitystringThe job identity's user SID.
logon_sessionintegerThe job identity's logon session identifier.
descriptionstringAs submitted.
image_pathstringAs submitted.
pidinteger or nullThe process identifier, while there is a process.
readybool or nullFor a notify job: whether READY=1 has arrived. Null for a none job.
exit_codeinteger or nullThe exit code, when the process exited.
exit_signalinteger or nullThe signal, when the process was killed.
status_textstring or nullThe most recent STATUS the job sent (§4.19).
progressobject or nullThe most recent progress the job sent (§4.19).
created_atstringWhen the record was created. §4.5
started_atstring or nullWhen exec was confirmed.
ended_atstring or nullWhen the job became terminal.

A field that does not apply MUST be present and null, never omitted.

These are all the view's fields on this channel. On the control channel each view carries one more, granted: the job rights the caller holds on the job (§4.14). It describes the caller rather than the job, and a submitter on this channel is not given it.

identity and submitter are user SIDs. A client that needs the identity's groups, privileges or integrity does not get them here; they are the job's, not the view's.

progress carries current (integer), total (integer or null), bounded (bool) and unit (string or null), in the representation §4.19 defines. A progress object whose total is null is either unbounded or not yet bounded, and bounded distinguishes them.

7.7.1 States #

StateProcess?Terminal?Meaning
createdNot yetNoThe record exists; exec has not been confirmed. Never visible on this channel — a submit is answered only after leaving it.
runningYesNoExec succeeded and the process is alive.
completedNoYesThe process exited with 0 or a code in success_exit_codes.
failedNoYesThe process exited otherwise, was killed by a signal, or never ran because setup failed.
abandonedYes, unkillablyYesThe process survived the kill. The manager has stopped supervising it.

These are the states of every job the manager runs, not a set invented for this channel; a client that understands them for a service's main job understands them here. A stopped job whose process died to the termination signal is failed with exit_signal set, and cause says the stop was asked for.

Which fields are populated is a function of state, and a client MAY rely on it:

Statepidstarted_atended_atexit_code / exit_signal
runningsetsetnullnull
completednullsetsetexactly one set
failed, process rannullsetsetexactly one set
failed, setup failednullnullsetboth null
abandonedsetsetsetboth null

exit_code and exit_signal are never both set: a process exits or is killed. For abandoned, ended_at records when supervision stopped; nothing exited.

7.7.2 Causes #

cause is set when the manager itself brought the job to its end, or decided it could not start, and null when the process ended of its own accord — a clean exit, a crash, a signal from elsewhere.

ValueStateMeaning
parent_setup_failurefailedThe manager could not prepare the launch: no token, no cgroup, no fork. No process existed.
pre_exec_failurefailedThe child's setup between fork and exec failed, or exec itself did.
readiness_timeoutfailedA notify job did not send READY=1 within readiness_timeout. The manager stopped it.
timeoutfailedThe job ran longer than timeout. The manager stopped it.
explicit_stopfailedA stop or job-stop was issued and the process died to it.
shutdownfailedThe system was shutting down and the manager stopped it.
process_unkillableabandonedThe process survived the kill.

A stop whose process handled the termination signal and exited 0 produces completed with cause explicit_stop: the manager asked, the process agreed, and both facts are recorded.

7.7.3 Retention #

The manager MUST hold a terminal job's record for at least a grace period after it reaches a terminal state, so that a submitter polling for the outcome can retrieve it. The value a Peios service manager uses is in §7.A.

An identifier that never existed and one whose record has been dropped MUST both be answered UNKNOWN_JOB, and a submitter MUST NOT distinguish them (§4.14's rule for operations, applied to jobs).

A job that is terminal stops counting against its submitter's quota at once; its record's retention is for the submitter's benefit, not a cost to it.

7.8 Managing a Job

Peios / Peios technical reference / PSPU / Job Submission

Four commands act on an existing job. Every one is authorised against the job's own Security Descriptor, using the connection's token, with the rights and mapping below. There is no command the manager performs on a job without a check, and no principal exempt from one — the submitter included.

7.8.1 Rights #

RightBitGrants
JOB_QUERY0x0001Read the job view; wait on the job.
JOB_STOP0x0002Stop the job.
JOB_SIGNAL0x0004Send a signal to the job's process.
JOB_ALL_ACCESS0x0007All three.
Generic rightMaps to
GENERIC_READJOB_QUERY
GENERIC_WRITEJOB_STOP | JOB_SIGNAL
GENERIC_EXECUTEJOB_STOP | JOB_SIGNAL
GENERIC_ALLJOB_ALL_ACCESS

The same rights and mapping govern the control channel's job commands (§4.7).

7.8.2 The job's descriptor #

Every job carries a Security Descriptor from the moment its record exists. Unless the submit supplied one, the manager MUST construct:

  • owner and group: the submitter's user SID;
  • a DACL granting JOB_ALL_ACCESS to the submitter, to SYSTEM (S-1-5-18) and to Administrators (S-1-5-32-544);
  • a SACL with one failure-audit ACE for Everyone (S-1-1-0) over JOB_ALL_ACCESS, so that a refused command on the job is recorded by the access check (§4.7);
  • nothing else. In particular the job identity is granted nothing: a process cannot, by virtue of running as U, see or stop a job that runs as U. A submitter that wants the principal to see its own session says so.

A submit MAY supply security_descriptor, in SDDL, and the manager MUST use it as given — it MUST NOT add its default entries to it. The supplied descriptor MUST carry an owner and a DACL, or the submit is INVALID_ARGUMENTS. A submitter that omits itself from the DACL it supplies has locked itself out of its own job, and the manager MUST NOT prevent that: the descriptor is the policy, and a policy the manager second-guessed would not be one.

The descriptor is fixed at submission. This chapter defines no command to change it.

7.8.3 status #

Returns the job view. Requires JOB_QUERY.

7.8.4 wait #

Blocks until a condition holds, then returns the job view. Requires JOB_QUERY.

forReturns when
terminal (default)The job reaches a terminal state.
readyThe job has sent READY=1, or reaches a terminal state, whichever first.

for: ready on a readiness: none job MUST be answered INVALID_STATE: there is nothing to wait for. On a job that is already ready, or already terminal, the manager MUST answer at once.

A connection blocked on a wait is not idle (§7.3). The wait is bounded by nothing but the job: a wait on a session job with no timeout blocks until something stops it. A submitter that needs a bounded wait polls status instead, or waits on the process handle it was given.

If the job's record is dropped while a wait is blocked on it — which can only happen after it became terminal and its grace period elapsed, so only if the manager was unable to answer in time — the wait MUST be answered UNKNOWN_JOB.

7.8.5 stop #

Asks the manager to end the job. Requires JOB_STOP.

The manager MUST send the termination signal to the job's process, wait stop_timeout, and then kill everything remaining in the job's containment. A job that has sent STOPPING=1 MUST NOT receive the termination signal (§4.19); the deadline still applies.

wait defaults to true: the response is sent when the job is terminal. With wait: false the manager MUST respond as soon as the stop has been initiated, with the job view as it then is.

A stop on a job that is already terminal MUST return the job view unchanged, with "status": "ok". It is not an error to stop something that has stopped; it is a no-op, and the view is the honest answer. A second stop on a job already being stopped MUST NOT restart the deadline.

7.8.6 signal #

Sends one signal to the job's main process, and nothing else. Requires JOB_SIGNAL.

signal MUST be an integer naming a signal the platform defines, or the request is INVALID_ARGUMENTS. The manager MUST deliver it to the process through the handle it obtained at fork, so that a recycled PID can never be signalled by mistake, and MUST answer with the job view.

A signal on a job that is not running MUST be answered INVALID_STATE.

This is the raw mechanism, deliberately: SIGKILL through signal kills the main process and leaves whatever it spawned, and produces a failed job with exit_signal set and a null cause. A submitter that wants the job ended uses stop.

7.8.7 What none of them do #

None of these commands moves a job backwards or starts it again. A terminal job stays terminal, its record is dropped after the grace period, and a submitter that wants the program to run again submits it again.

7.9 Descriptors and Output

Peios / Peios technical reference / PSPU / Job Submission

7.9.1 Descriptors into the job #

A submit MAY attach descriptors with SCM_RIGHTS. The manager MUST give them to the job's process, in the order they were attached, in exactly the form §4.20 gives stored descriptors back to a service:

  1. Placed consecutively from descriptor 3, with close-on-exec cleared.
  2. LISTEN_FDS set to the count.
  3. LISTEN_FDNAMES set to the names from descriptors, colon-separated, in the same order.
  4. LISTEN_PID set to the job process's own PID.

All four MUST be absent when nothing is passed.

descriptors names them. It MUST have exactly one entry per descriptor to be injected — the attached descriptors, less the output sink if output is true — or the submit is INVALID_ARGUMENTS. A name MUST be non-empty and MUST NOT contain :, since : is the separator in LISTEN_FDNAMES.

A submitter MAY attach nothing and send an empty descriptors; that is the common case.

Note

This is what makes a session job possible without inventing a channel: the submitter creates a socketpair, keeps one end, and attaches the other, and the session process finds it at descriptor 3 under whatever name the submitter chose. It is also what makes a terminal possible — attach the pseudo-terminal's slave — and every other case where the program needs a handle its submitter already holds. The convention is sd_listen_fds's, so a program written to adopt descriptors from a service manager adopts them from a submitter unchanged.

The manager MUST NOT inspect, validate or restrict what an attached descriptor refers to. What a job may do with a descriptor is decided by the rights cached on it, which the submitter established when it opened it.

7.9.2 The job's output #

A job's standard output and standard error MUST be captured by the manager and recorded exactly as a service's are — the manager's own design says where, and on Peios that is eventd, tagged with the job's identifier. This is unconditional: a submitter cannot turn it off, and cannot redirect the job's streams somewhere the manager does not see.

A submitter MAY additionally ask for a copy. With output: true, the last attached descriptor is not injected into the job; the manager adopts it as an output sink and writes to it every line it reads from the job's streams, as it reads them.

The copy is best-effort, and the record is not:

  • The manager MUST NOT block on the sink, and MUST NOT let a sink that is not being drained slow the job or the manager. It MUST put the sink in non-blocking mode.
  • When a write to the sink would block, the manager MUST drop the line for the sink only, MUST still record it, and MUST count the drop. It MUST report that drops occurred — at least once per job, through its event stream — so a submitter that sees a gap can learn it caused one.
  • The manager MUST close the sink when the job is terminal, after the last line has been written or dropped, and MUST close it at once if writing to it fails with anything other than a would-block condition.

What the manager writes to the sink is each line the job produced, followed by a newline, with no tagging: the submitter attached the sink knowing which job it was for. Lines from the two streams are interleaved in the order the manager read them. A submitter that needs to tell them apart, or needs every line, reads the record instead.

output: true with no attached descriptor is INVALID_ARGUMENTS.

7.10 Errors

Peios / Peios technical reference / PSPU / Job Submission

The code field of an error response MUST be one of these values. The manager MUST NOT emit any other code on this channel, and a submitter MUST treat a code it does not recognise as an unrecoverable error for that request (§7.11).

CodeMeaningCloses?
MALFORMED_REQUESTThe message is not a single well-formed JSON object. §7.4No
REQUEST_TOO_LARGEThe message exceeds the configured maximum, or was truncated. §7.3Yes
INVALID_COMMANDcommand is absent, not a string, or names no command.No
INVALID_ARGUMENTSA field is absent, malformed, or inconsistent with the attachments. §7.6, §7.9No
UNKNOWN_JOBjob_id names nothing the manager holds — it never existed, or its retention has elapsed. §7.7No
ACCESS_DENIEDThe access check against the job's descriptor denied the right. §7.8No
INVALID_STATEThe command is not valid for the job's state, or the manager is shutting down.No
QUOTA_EXCEEDEDThe submitter already holds its maximum of live jobs. §7.3No
BAD_TOKENThe attached token cannot be a job identity. §7.5No
INTERNAL_ERRORThe manager failed while executing the command.No

UNKNOWN_SERVICE, UNKNOWN_OPERATION and OPERATION_TIMEOUT are not emitted here: nothing on this channel names a service or an operation, and a wait has no timeout of its own to expire.

7.10.1 Distinctions a submitter can rely on #

UNKNOWN_JOB versus ACCESS_DENIED. A caller that may not query a job receives ACCESS_DENIED for an identifier that exists, and UNKNOWN_JOB for one that does not. This channel does not hide the existence of a job from a caller that can name it: identifiers are unguessable, and a caller that holds one was told it by something that had it.

BAD_TOKEN is about the token, never about the identity. It says the attached token is unusable — its level, its count, its rights — and never that the submitter may not act as the principal it names. That decision was the kernel's, made before the message arrived, and a submitter that could not convey an identity never reaches the manager with it.

QUOTA_EXCEEDED created nothing. No record, no event, no consumption. A submitter MAY retry once one of its live jobs ends.

INVALID_STATE during shutdown. Once the manager is shutting down it MUST reject submit with INVALID_STATE and MUST continue to answer status, wait, stop and signal; a submitter watching its jobs be stopped has a legitimate reason to keep looking, and a submitter that wants a job stopped sooner than the shutdown will manage has a legitimate reason to ask.

7.10.2 Codes are not extensible without a version #

The manager MUST NOT introduce a new code without the negotiation in §4.21, for the reason §4.10 gives: an unrecognised code silently turns a handled condition into an unhandled one.

7.11 Extension

Peios / Peios technical reference / PSPU / Job Submission

The jobs channel carries no version number, and is extended under the rules of §4.21. They are not repeated; what follows is how they apply here and what this chapter adds.

7.11.1 What may be added #

A request field, including a new definition field on submit. A manager MUST ignore a field it does not recognise, so a submitter MAY send one and MUST NOT depend on its effect.

A job view field. A client MUST ignore a field it does not recognise.

A command, and the error codes and enumerated values that appear only in responses to it, on the terms §4.21 gives: a client that never sends the command is never exposed to them.

7.11.2 What may not be added without a version #

A state, a cause, a progress unit, or a for value outside §7.B. A command changing what an existing command does. A change to the shape of the job view. A new error code on an existing command.

7.11.3 What may never be added #

Two things are excluded from extension by any means, because their absence is a security property of the channel rather than a gap in it:

  • An identity field. A submit MUST NOT be able to name the principal the job runs as. Every job identity is one the kernel verified the submitter could convey (§7.5), and a field that named one would be a path around that verification.
  • A policy field. A submit MUST NOT be able to ask for a restart, a dependency, a health check or a trigger. A job that needs policy is a service, and a service is defined where an administrator controls it.

A revision that wanted either would not be a revision of this channel.

7.12 Conformance

Peios / Peios technical reference / PSPU / Job Submission

7.12.1 A conforming manager #

The socket. Listens on a Unix sequenced-packet socket at the well-known path, close-on-exec on the listener and every accepted connection, and ensures the socket and each directory containing it carry a Security Descriptor admitting exactly the principals meant to submit. Performs no access check of its own before a submit (§7.3).

Messages. Emits and accepts exactly one compact JSON object per record, with no terminator. Receives every message with room for one token and the maximum number of descriptors, answers a truncated message or attachment list without acting on it, and closes every attached descriptor it does not hand on or adopt, on every path (§7.4).

Identity. Establishes the submitter from the kernel once, at accept. Installs on a job exactly one of: the connecting process's own primary token, opened through the kernel's peer process handle; or the token the kernel attached to the submit, duplicated to a primary. Refuses a token below Impersonation level, more than one token, or one it cannot duplicate, with BAD_TOKEN. Accepts no identity by name and no primary token by plain descriptor (§7.5).

Submission. Validates the definition as §7.6 lists and refuses with INVALID_ARGUMENTS; does not check that the program exists. Checks identity and quota before creating any record. Answers a submit when the job has left created and not before or after, with the job view, attaching the process handle when the job is running (§7.6).

The job view. Reports every job in the one shape of §7.7, with every inapplicable field present and null, the state-dependent population rules honoured, and the cause set only where the manager decided the outcome. Holds a terminal record for the grace period and answers a dropped or never-existent identifier UNKNOWN_JOB alike.

Management. Gives every job a descriptor — the default of §7.8 or the one supplied — and checks every command against it with the rights and mapping there, including commands from the submitter. Implements status, wait, stop and signal with the semantics of §7.8: a wait bounded only by the job, a stop that is a no-op on a terminal job and does not restart a running deadline, a signal delivered through the fork-time handle.

Descriptors and output. Injects attached descriptors from 3 upward with LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID set; records the job's output unconditionally; and, when asked, copies it to a non-blocking sink, dropping for the sink only, counting and reporting the drops, and closing the sink when the job ends (§7.9).

Errors and extension. Emits only the codes in §7.10, ignores unrecognised request fields, and adds nothing from §7.11's closed list — and never an identity or policy field — without a negotiated version.

7.12.2 A conforming submitter #

Connects as the identity it wants to own its jobs, and conveys any other identity per message rather than by impersonating at connect. Attaches at most one token, and sets its connection's level to Delegation before attaching when the job needs it. Sends one compact JSON object per record. Supplies every LISTEN_FDNAMES name it attaches a descriptor for, and none containing :. Assumes the manager owns every descriptor it attached. Treats an immediate close with no response as a refusal. Does not parse message. Accepts null for every nullable field and unrecognised fields in the job view. Treats an unrecognised state, cause, unit or error code as an error for that request rather than guessing. Reads state rather than cause to decide whether a job succeeded. Uses stop to end a job and signal only to signal it. Does not depend on a job's record surviving its grace period.

7.12.3 A conforming job #

Nothing beyond §4.22's requirements of a service on the notification channel. A job that sends progress does so in the form §4.19 defines. A job that adopts descriptors checks LISTEN_PID first.

7.12.4 What conformance is not #

A manager that offers the control channel and not this one is still a conforming manager under §4. This chapter is the contract for one that offers both.

Appendix 7.A Limits and Defaults

Peios / Peios technical reference / PSPU / Job Submission

The values a Peios service manager uses. A manager MAY use different ones; where a value is configurable, it MUST be discoverable to an administrator through the same surface that sets it.

7.A.1 The socket #

BoundValueConfigurableDefined in
Socket path/run/services/peinit/jobs.sockNo§7.3
Socket descriptor, defaultConnect for Authenticated Users (S-1-5-11); full control for SYSTEM and AdministratorsYes§7.3
Concurrent connections64Yes§7.3
Message size65536 bytesYes§7.3
Idle timeout30 secondsYes§7.3
Listen backlog32No§7.3
Live jobs per submitter64; SYSTEM exemptYes§7.3

7.A.2 Attachments #

BoundValueDefined in
Tokens per message1§7.4
Descriptors per message64, including an output sink§7.4
First injected descriptor3§7.9

7.A.3 The definition #

FieldDefaultDefined in
stop_timeout10 seconds§7.6
readiness_timeout30 seconds§7.6
timeout0, no limit§7.6
working_directory/§7.6

7.A.4 Records #

BoundValueDefined in
Terminal record retention60 seconds§7.7

The retention value is the control channel's operation retention (§4.A), for the same reason: a client polling once a second must never find the outcome gone.

7.A.5 Composing with the control channel #

A job's status_text and progress come from datagrams on the notification channel, whose bound is §4.A's 65536 bytes; the job view is not bounded by the jobs channel's message size on the way out, so a status a job can send is always one the manager can return.

The message-size bound applies to a submit and therefore to the sum of its definition: a program with a very large environment or arguments list is bounded here before it is bounded by execve.

Appendix 7.B Wire Vocabulary

Peios / Peios technical reference / PSPU / Job Submission

Every enumerated value that appears on the jobs channel. All are lower snake case. A manager MUST NOT emit a value outside these sets without the negotiation in §4.21, and a submitter MUST treat one it does not recognise as an error for that request rather than mapping it onto a value it knows.

Values shared with the control channel — response status, job type — are §4.B's and are not restated.

7.B.1 Command #

submit, status, wait, stop, signal

7.B.2 Job state #

ValueProcess?Terminal?
createdNot yetNo
runningYesNo
completedNoYes
failedNoYes
abandonedYes, unkillablyYes

created is never reported on this channel (§7.6). It appears here because the control channel's job-list may show it for a job whose submit is still in flight.

7.B.3 Cause #

parent_setup_failure, pre_exec_failure, readiness_timeout, timeout, explicit_stop, shutdown, process_unkillable

cause is null when the process ended of its own accord.

7.B.4 Readiness #

none, notify

Request-only, on submit. The job view reports readiness through ready, not by echoing this.

7.B.5 Wait condition #

terminal, ready

Request-only, the for field of wait.

7.B.6 Progress unit #

bytes, items, percent

The vocabulary of PROGRESS_UNIT (§4.19), reported in the job view's progress.unit. Null when the job sent none.

8.1 Scope and Roles

Peios / Peios technical reference / PSPU / Security Descriptor Editing

This chapter defines the editing channel: the interface by which a program has a person edit the Security Descriptor of an object the program holds, in a dialog drawn by another program, the editor.

The object may be of any kind that has a Security Descriptor — a file, a registry key, a service. The editor knows none of them. What it is told is what a person needs in order to edit the descriptor: what the object is called, what kind of thing it is, what its rights are called, and the descriptor itself. What it answers with is a descriptor.

8.1.1 Roles #

The requester is the program whose object it is. It starts the editor, reads the object's descriptor, tells the editor about it, and applies each descriptor the editor sends back. It holds the object, and whatever right changing the descriptor takes, throughout.

The editor draws the descriptor for a person to change, on the desktop the requester is on, and sends back what they apply. It holds no handle to the object and needs no right over it. It changes nothing itself.

The split is deliberate. The party that can change the object is the one that already holds it, and it applies exactly what it is sent, so an editor serves every kind of object without ever being trusted with one. A requester gains a full editor without containing one.

Note

The arrangement is the one Windows' security editor uses, where the caller implements the object's side (ISecurityInformation: the descriptor, the rights' names, applying) and the editor draws. Here the editor is a separate process rather than a library, so that it serves a requester in any language, and a fault in it is its own.

8.1.2 What this chapter leaves to others #

How the editor draws its dialog, and on which desktop, is the editor's own design and that desktop's. On a GXWI desktop the editor is an app like any other, found by the environment it inherits from the requester.

The layout of a Security Descriptor, of the access masks it carries and of its entries is PCDS §5's. This chapter carries descriptors and says nothing about their contents beyond which components are applied.

How a requester reads and applies a descriptor is its own concern, and depends on the kind of object. A requester SHOULD apply through the same handle it read through, so that what is applied reaches the object that was shown, wherever it has gone in the meantime.

8.2 Terminology

Peios / Peios technical reference / PSPU / Security Descriptor Editing

Security Descriptor, access mask, ACE, DACL, SACL, owner, group, and generic right are used with PCDS §5's meanings and are not redefined.

Requester. The program whose object's descriptor is being edited, and which started the editor. §8.1

Editor. The program that shows the descriptor to a person and sends back what they apply. §8.1

Line. One message on the editing channel: a single compact JSON object, encoded as UTF-8, followed by a line feed (U+000A). §8.3

Request. The first line the requester sends: everything the editor is told about the object. §8.4

Component. One of the five separately applicable parts of a Security Descriptor: the owner, the group, the DACL, the SACL and the label. The label is the SACL's mandatory integrity label alone, which KACS applies apart from the rest of the SACL and with a different right (KACS set-security). On the channel they are named owner, group, dacl, sacl and label.

General right. A right the requester names for a person to grant or deny in one step, such as "Read" or "Full control", standing for an access mask that may have many bits. The requester decides which rights are general and what each stands for. §8.4

Applying. The requester writing components of a descriptor the editor sent onto the object. §8.5

8.3 The Channel

Peios / Peios technical reference / PSPU / Security Descriptor Editing

8.3.1 Starting the editor #

The requester MUST start the editor as a child process of its own, with the editor's standard input and standard output connected to it. It SHOULD start the editor with its own environment, which is how the editor finds the desktop to draw on.

The editor's standard error is not part of the channel. The editor MAY write diagnostics there for an administrator, and the requester MUST NOT read meaning into them.

8.3.2 Lines #

Each direction is a sequence of lines (§8.2): one compact JSON object, UTF-8, then a line feed. A line MUST NOT contain a line feed before its end; compact JSON never needs one.

  • Requester to editor, on the editor's standard input: the request (§8.4), first and exactly once, then one answer for each apply (§8.5), and raise (below) whenever the person asks for the object again.
  • Editor to requester, on the editor's standard output: apply messages (§8.5), and nothing else in this revision.

Every line carries a type member naming what it is, except the request, which is identified by being first.

A party MUST ignore a line whose type it does not recognise, and a member of an object it does not recognise (§8.7). A party MAY refuse a line longer than 1 MiB; a request carrying a descriptor of the largest size PCDS §5.1 permits is well inside it.

8.3.3 Asked for again #

A requester that the person asks to edit an object it already has an editor open on SHOULD NOT start a second editor for it: two dialogs on one object would each apply over the other. It SHOULD instead send

{"type":"raise"}

which needs no answer. An editor that receives it SHOULD bring its dialog to the front and give it the keyboard, as far as the desktop lets it. An editor that does not recognise it ignores it, as any line, so a requester MUST NOT depend on the dialog coming forward. A requester whose write of raise fails has learned the editor has gone, and MAY start a new one.

8.3.4 Ending #

The channel ends when either party closes its end, and either party can tell when the other has.

The editor ends when the person closes or cancels the dialog, and after the person confirms once what it sent has been applied (§8.5). It ends by exiting, which closes its standard output; the requester reading end-of-file there is how it knows. An editor that ends sends nothing more and applies nothing, since it never could.

The requester ends, or gives up on the editor, by closing the editor's standard input. The editor MUST end promptly when it reads end-of-file there, whatever it is doing, and MUST NOT ask the person anything first: the requester can no longer apply what they would say. A requester that exits closes the editor's input whether it means to or not, so the editor goes with it.

The editor SHOULD also arrange to be signalled when its parent exits (on Linux, PR_SET_PDEATHSIG), and end then. A process whose parent exits is not ended by that, and an editor left showing a dialog for an object nobody can apply to is worse than none.

The editor's exit status says nothing about what was applied. The requester knows that from its own answers (§8.5), and MUST NOT infer it from the status.

8.4 The Request

Peios / Peios technical reference / PSPU / Security Descriptor Editing

The request is the first line on the editor's standard input, and the only one that has no type. It is one JSON object:

MemberTypeRequiredMeaning
objectobjectYesWhat the descriptor is the descriptor of.
object.namestringYesWhat the object is called where the person found it: notes.txt, not a full path, unless a path is how they know it.
object.kindstringYesWhat kind of thing it is, in words for the person: File, Folder, Service.
object.containerbooleanNo, falseWhether other objects inherit from it (PCDS §5.6).
object.childrenstringNo, "all"What a container holds, which is what the entries made for it are passed on to: all or containers (see Containers). Ignored unless container is true.
object.parentobjectNoWhat the object is in, which its inherited entries come from (see The parent).
object.parent.namestringYes, in parentWhat the parent is called where the person knows it: /srv.
object.parent.sdstringNoThe parent's descriptor, self-relative and in base64 as sd is.
object.partsarrayNo, emptyThe object's parts that rules can be made for one at a time: object types (see Parts and kinds).
object.parts[].guidstringYesThe object type, as a GUID in its usual text form: bf967aba-0de6-11d0-a285-00aa003049e2.
object.parts[].namestringYesWhat the part is called, for the person.
object.parts[].kindstringYesset, a set of properties; property; or right, an action.
object.parts[].setstringNoFor a property, the GUID of the set it is in.
object.kindsarrayNo, emptyOn a container of typed things, the kinds of thing it holds: inherited object types.
object.kinds[].guid, .namestringYesThe inherited object type, and what that kind of thing is called.
object.part_rightsarrayNo, emptyThe rights a rule for a part can give, as rights gives the object's (see Parts and kinds).
object.part_rights[].name, .maskstring, numberYesWhat the right is called, and its access mask.
object.namingobjectNoHow a part not listed is named (see Parts named by the person).
object.naming.namespacestringYes, in namingThe UUID v5 namespace a part's object type is derived in, as a GUID in its usual text form.
object.naming.nounstringYes, in namingWhat one such part is called, for the person: field.
object.naming.examplestringNoA name the person might give, to show how one is written: source.name.
sdstringYesThe descriptor as it is now: a self-relative Security Descriptor (PCDS §5.1), in base64 with padding (RFC 4648 §4).
readarray of stringsNoThe components sd holds as they are on the object: some of owner, group, dacl, sacl and label (see The descriptor). Left out, every component sd has.
rightsarrayYesThe object's rights by name, in the order a person should see them.
rights[].namestringYesWhat the right is called.
rights[].masknumberYesThe access mask the right stands for, as an unsigned 32-bit integer.
rights[].generalbooleanNo, falseWhether it is a general right (§8.2).
genericobjectYesWhat each generic right stands for on this kind of object (PCDS §5.3).
generic.read, .write, .execute, .allnumberYesThe access mask each generic right maps to.
canobjectNoWhich components the requester is able to change.
can.daclbooleanNo, trueWhether it can change the DACL. It is true when left out because, before this member, a requester always could.
can.ownerbooleanNo, falseWhether it can change the owner.
can.auditbooleanNo, falseWhether it can change the SACL.
can.labelbooleanNo, falseWhether it can apply the label by itself (§8.5).
can.propagatebooleanNo, falseWhether, on a container, it can push what was applied into what is already inside (§8.5, Pushing into what is inside).
can.whystringNoWhy the requester cannot change what can says it cannot, as text for the person.

8.4.1 The descriptor #

The requester SHOULD include the owner, the group and the DACL. It MAY leave out the SACL: reading a SACL takes ACCESS_SYSTEM_SECURITY, which a requester need not have. It SHOULD include the label where it can read it, which takes only READ_CONTROL. A requester that could read the label and not the rest of the SACL sends a SACL holding the label alone, and MUST then say so in read, naming label and not sacl.

The editor MUST NOT take a SACL for the whole SACL unless read names sacl or is left out. Where it is only the label, the editor MUST NOT show it as an object with no auditing, claims or policies, since those are not known.

A descriptor with no DACL component means one with no access list, which PCDS §5.1 treats as granting everyone everything. The editor MUST show it as such and not as an empty list, which grants nobody anything.

8.4.2 The parent #

object.parent names what the object's inherited entries come from, for the person: "Inherited from /srv". An editor that offers to stop inheriting MAY offer to inherit again, but KACS does not pass a parent's entries down again by itself when protection is turned off (PCDS §5.6): the editor works out what is passed down from object.parent.sd, and SHOULD NOT offer to inherit again without it. A requester SHOULD send the parent's descriptor where it can read it.

8.4.3 Parts and kinds #

object.parts lists the object types (PCDS §5.4) a rule can be made for, as an account's sign-in details or the action of resetting its password. object.kinds lists, for a container of typed things, the kinds of thing it holds, which an entry can be passed on to alone. An editor MAY offer rules for parts and kinds only where they are listed, and MUST keep as it found it an entry naming a GUID it is not told of.

object.part_rights names what a rule for a part can give. Left out, an editor offers what a directory's parts take: reading and writing a property, and using an action. A requester whose parts take other rights MUST send them. eventd's fields, for example, take only its Read.

8.4.3.1 Parts named by the person #

Some objects have more parts than can be listed, each one's object type derived from its name. eventd's fields are an example: a payload field needs no registration, and its GUID is derived from its name (eventd TRM §7.3). object.naming tells the editor how such a part is named. Its object type is the UUID v5 (RFC 9562 §5.5) of the name, as UTF-8, in naming.namespace, and it is a property.

An editor MAY then let the person name a part beside those listed. It MUST derive the object type exactly so, and SHOULD show the part by the name given for as long as the dialog is open. A requester SHOULD still list in parts the ones it knows, so that an existing rule for one is shown by name.

8.4.4 The rights #

rights names every right the requester wants a person to see. General rights SHOULD come first, from the one granting the most to the one granting the least, so that a right that includes another is above it. The masks of two rights MAY overlap, and typically do: "Modify" includes "Read".

An editor that shows general rights as boxes to tick SHOULD show as a whole what a mask it cannot express in them — a mask with bits outside every general right it contains, an entry of a type it does not edit, or inheritance flags other than its own — rather than leave it unshown, and SHOULD keep such an entry as it found it.

generic lets the editor read an entry that grants generic rights (PCDS §5.3) as the rights they stand for. The requester MUST give the mapping its kind of object actually uses.

8.4.5 Containers #

On a container, the entries an editor makes apply to the container and are passed on to what it holds. object.children says what that is, and so which inheritance flags (PCDS §5.6) those entries carry:

ValueHoldsFlags of an entry made here
allContainers and other objects, as a folder holds folders and files.Object inherit and container inherit.
containersContainers alone, as a registry key holds keys and nothing else.Container inherit.

An entry with other inheritance flags is one the editor cannot express in its general rights (above), and is shown as a whole and kept.

8.4.6 What the requester can change #

can says what the requester is able to change. It MUST NOT say it can change a component it cannot. can.label is true only where it can apply the label by itself, which takes WRITE_OWNER and, for a level above the caller's own, SeRelabelPrivilege (KACS set-security). It SHOULD find out by asking the system, for example by an AccessCheck of the person's token against the descriptor, or by opening the object for the rights that changing it takes. It SHOULD NOT infer it from the person's group memberships.

can.propagate is true only on a container whose requester holds what is inside it and can walk it: a file explorer for a folder, a registry editor for a key. It says nothing about whether each item inside can be changed, which is found out item by item as the walk goes (§8.5).

A requester that cannot change the DACL still sends it, so that the person can read it. The editor MUST then show the DACL without offering to change it, and SHOULD say why, with can.why if it was given. An editor whose requester can change nothing SHOULD offer only to close.

8.4.7 Validation #

An editor that cannot use the request — one that is not a JSON object, lacks a required member, or carries an sd that is not a Security Descriptor — MUST NOT show a dialog. It SHOULD write why to its standard error, and MUST end (§8.3).

8.5 Applying

Peios / Peios technical reference / PSPU / Security Descriptor Editing

8.5.1 apply #

When the person asks for what they have done to take effect — Apply, or OK — the editor sends:

MemberTypeMeaning
type"apply"
sdstringThe whole descriptor as the person now has it, self-relative and in base64 as in the request (§8.4).
partsarray of stringsThe components of sd that changed since the request, or since the last apply the requester answered applied: some of owner, group, dacl, sacl, label.
propagatebooleanOptional, false when left out. Once parts is applied, push what the object now passes down into what is already inside it (see Pushing into what is inside).

The editor MUST NOT send an apply as the person changes things, only when they ask for it. A descriptor applied part way through a change can take away the access the person needs to finish it: denying Everyone before granting oneself, say.

The editor MUST NOT name dacl in parts unless the request's can.dacl was true or left out, nor owner or group unless can.owner was true, nor sacl unless can.audit was and the request held the whole SACL, nor label unless can.label was. It MUST NOT send propagate true unless can.propagate was, nor without the person having chosen it for this apply. It MUST NOT send an apply while one is unanswered, and SHOULD NOT send one whose parts is empty. It MAY send one whose parts names components that have not changed since they were applied, with propagate true, to push them into what is inside again (after a walk was stopped or some of it failed): applying them again changes nothing.

8.5.2 The label #

The label is in the SACL, and sacl carries it: a changed label goes with a changed SACL. parts MUST NOT name both sacl and label, which KACS refuses to apply together (KACS set-security). label names the label alone, where the rest of the SACL has not changed or cannot be applied: the SACL of sd then holds the label that is to be the object's, or none to remove it, and the requester applies it as a label (LABEL_SECURITY_INFORMATION), which keeps the rest of the object's SACL as it is. A requester that keeps a descriptor whole, as a registry value, puts the label into the SACL it has, in place of the label there.

8.5.3 The answer #

The requester MUST answer every apply with exactly one line, in the order they came:

LineMeaning
{"type":"applied"}Every component in parts was applied. Where propagate was true, it carries how pushing into what is inside went (below).
{"type":"failed","why":"…"}None was, or not all, and why says why, as text for the person. Nothing was pushed into what is inside.

The requester MUST apply the components named in parts, and MUST NOT apply any other component of sd. The rest is as the requester sent it, or as the editor could not read it, and writing it back would at best change nothing and at worst write over what changed meanwhile.

The requester SHOULD apply the named components in one operation where the kind of object allows it. Where it cannot, and some were applied and some were not, it MUST answer failed and SHOULD say which were applied.

why is shown to the person as it stands. It SHOULD say what was in the way in words they can act on — "you are not allowed to" — rather than an error code.

8.5.4 Pushing into what is inside #

Inheritance is eager (PCDS §5.6): what a container passes down reaches the things made inside it afterwards, and the things already there keep what they were given. Where the person changes what a container passes down, the editor SHOULD ask whether to update what is already inside too, and, where they say yes, sends the apply with propagate true.

A requester sent propagate true first applies parts as above. If it answers failed, it pushes nothing. Otherwise, before it answers, it re-propagates everything inside the object as PCDS §5.6 sets out (Re-propagation), for the lists parts names: the DACL for dacl, the SACL for sacl, and for label the SACL as far as it holds labels, read and applied as labels. In particular it MUST:

  • go parents first, re-propagating each item from its parent as just rewritten;
  • leave a list an item protects exactly as it is, and still re-propagate what is inside that item from it as it is;
  • resolve CREATOR OWNER and CREATOR GROUP against each item's own owner and group, and map generic rights through the generic mapping of the kind of thing it holds;
  • change no component of an item but the lists it re-propagates;
  • follow no link out of the tree;
  • carry on past an item it cannot read or change, noting it, and leave what is inside that item as it is.

The editor sends nothing for the items inside: what each gets is worked out by the requester, from the object as it now is.

While it walks, the requester MAY send:

LineMeaning
{"type":"progress","done":n,"at":"…"}done items have been re-propagated, the last being at, by what the person knows it as.

It needs no answer. A requester SHOULD send one now and then on a long walk, and SHOULD NOT send them more often than a person can read.

The editor MAY send, while it waits for the answer to an apply with propagate true:

LineMeaning
{"type":"stop"}Stop the walk after the item in hand.

It needs no answer of its own: the requester stops, and answers the apply as it would have at the end. What was re-propagated stays so. End-of-file on the requester's input from the editor means the same.

The applied answer to an apply with propagate true carries:

MemberTypeMeaning
donenumberHow many items inside were re-propagated, those that protect every list included. Left out, none.
failedarrayThe items that could not be, each {"name":"…","why":"…"}: what the person knows it as, and why, as text for the person. Left out, none.
stoppedbooleanWhether the walk was stopped before the end. Left out, it was not.

Each of these concerns the items inside, not the object: applied still means the object's parts were all applied. The editor SHOULD show how far the walk got, a way to stop it, and afterwards what could not be done, and SHOULD NOT end on an apply-and-close whose walk failed for some items or was stopped, so that the person sees it.

8.5.5 After the answer #

On applied, the editor takes what it sent as the descriptor it is editing from then on: a later apply names only what changed after. If the person had asked to apply and close, the editor ends (§8.3).

On failed, the editor MUST show the person why and MUST NOT end because of it: what they did is still there to be put right or abandoned. What it has not had answered applied it still counts as changed.

8.6 Extension

Peios / Peios technical reference / PSPU / Security Descriptor Editing

The editing channel carries no version number. It grows by addition, under these rules.

8.6.1 What may be added #

A member of the request, or of any line. A party MUST ignore a member it does not recognise, so a requester MAY send one and MUST NOT depend on its effect.

A line type, in either direction, that the other party may ignore and still conform: a party MUST ignore a type it does not recognise (§8.3). A line that needs an answer MUST NOT be added this way, since an editor waiting on an answer from a requester that ignored the question would wait for ever.

8.6.2 What may not be added without a version #

A change to the meaning of an existing member or line. A component name beyond the five of §8.2. An answer to apply other than the two of §8.5.

The fifth, label, was added without a version because nothing can send it to a requester that does not know it: an editor names it only for a requester that said can.label, which an older requester never says.

Pushing into what is inside was added the same way. An editor sends propagate only to a requester that said can.propagate. progress and stop are lines that need no answer. What applied carries about the walk is members of it, and applied still means only that the object's parts were applied.

object.part_rights and object.naming were added as optional members of the request. An editor that does not know naming offers only the parts listed. An editor that does not know part_rights offers a directory's rights for a part, which may not be rights the object's parts take. For eventd's fields they aren't, so such a rule gives nothing.

raise (§8.3) was added as a line that needs no answer. An older editor ignores it, and its dialog stays where it is.

8.6.3 What may never be added #

A way for the editor to apply. Nothing on the channel may let the editor change the object itself, or ask the requester to apply anything but a descriptor the person asked to apply. That the editor holds nothing and is trusted with nothing is what lets a requester open it on any object, and the requester applying exactly what it is sent is the whole of the contract between them.

Pushing into what is inside keeps to this. The editor sends no descriptor for anything but the object, and propagate asks only for what PCDS §5.6 says re-propagation of the object's descriptor is, which the requester works out itself from the object as it applied it. The editor cannot choose what any item inside gets.

8.7 Conformance

Peios / Peios technical reference / PSPU / Security Descriptor Editing

8.7.1 A conforming requester #

Starting. Starts the editor as its own child with the editor's standard input and output connected to it, and sends the request as the first line (§8.3).

The request. Sends every required member, a self-relative descriptor in padded base64, the general rights from the most to the least, and the generic mapping its kind of object actually uses. On a container, says in object.children what it holds. Says in can only what it can in fact change, having asked the system. Says in read which components it sent where that is not everything sd has, as a SACL holding only the label (§8.4).

Applying. Answers every apply with exactly one line, in order. Applies the components parts names and no others, and the label as a label, keeping the rest of the SACL. Answers applied only when all of them were, and otherwise failed, with a reason for the person (§8.5).

Pushing into what is inside. Says can.propagate only on a container it can walk. Asked to, after applying, re-propagates everything inside as PCDS §5.6 sets out, for the lists parts names: parents first, protected lists left, nothing changed but those lists, no link followed, past an item it cannot change. Stops after the item in hand on stop or end-of-file, and answers applied with how many were done, which could not be and why, and whether it was stopped (§8.5).

Lines. Ignores a line type and a member it does not recognise (§8.3). Infers nothing from the editor's exit status or standard error (§8.3).

8.7.2 A conforming editor #

Starting. Shows no dialog for a request it cannot use, and ends (§8.4).

Showing. Shows a descriptor without a DACL as granting everyone everything, never as an empty list. Shows what it cannot express in the general rights as a whole, and keeps such entries as it found them. Gives the entries it makes on a container the inheritance flags of what the container holds. Shows a DACL the requester cannot change without offering to change it. Takes a SACL for the whole SACL only where read says it is (§8.4).

Applying. Sends an apply only when the person asks, with the whole descriptor and the components that changed, never dacl, owner, group, sacl or label it was not told it could, never sacl and label together, and never while one is unanswered. Shows a failed answer's reason and stays (§8.5). Sends propagate only to a requester that said can.propagate, and only where the person chose it (§8.5).

Changing nothing. Holds no handle to the object and changes nothing itself (§8.1, §8.6).

Ending. Ends when the person closes or cancels, after an applied answer to an apply-and-close, and promptly on end-of-file on its standard input, without asking the person anything. SHOULD also end when its parent exits (§8.3).

Appendix 8.A Message Reference

Peios / Peios technical reference / PSPU / Security Descriptor Editing

DirectionLineDefined in
Requester → editorThe request (first line, no type)§8.4
Editor → requester{"type":"apply","sd":…,"parts":[…],"propagate":…}§8.5
Requester → editor{"type":"progress","done":…,"at":…} (while pushing into what is inside; no answer)§8.5
Editor → requester{"type":"stop"} (while pushing into what is inside; no answer)§8.5
Requester → editor{"type":"applied","done":…,"failed":[…],"stopped":…} (the last three only after pushing)§8.5
Requester → editor{"type":"failed","why":…}§8.5
Requester → editor{"type":"raise"} (the object asked for again; no answer)§8.3

8.A.1 An example #

A file explorer opens the permissions of a file. The person adds Everyone with Read and presses Apply, which fails, then changes their mind and closes the dialog. Lines are shown on several lines here for reading. On the channel each is one line, and the descriptors are shortened.

→ {"object":{"name":"notes.txt","kind":"File","container":false,
             "parent":{"name":"/home/dana","sd":"AQAEgBQAAAAkAAAA…"}},
   "sd":"AQAEgBQAAAAkAAAAAAAAADQAAAAB…",
   "read":["owner","group","dacl","label"],
   "rights":[{"name":"Full control","mask":2032127,"general":true},
             {"name":"Read","mask":1179785,"general":true}],
   "generic":{"read":1179785,"write":1179926,"execute":1179808,"all":2032127},
   "can":{"owner":true,"audit":false,"label":true}}
← {"type":"apply","sd":"AQAEgBQAAAAkAAAAAAAAADQAAAAC…","parts":["dacl"]}
→ {"type":"failed","why":"you are not allowed to"}

The editor shows why and stays open. The person closes it, and the requester reads end-of-file on the editor's output.

The requester could read the label but not the rest of the SACL, so read names label and not sacl, and the SACL in sd holds the label alone. Had the person raised the label, the editor would have sent "parts":["label"].

8.A.2 Pushing into what is inside #

The same explorer opens a folder's permissions, saying it can push into what is inside. The person gives Finance Modify on the folder and everything in it, presses OK, says yes to updating what is already inside, and stops partway.

→ {"object":{"name":"finance","kind":"Folder","container":true}, …,
   "can":{"owner":true,"audit":true,"label":true,"propagate":true}}
← {"type":"apply","sd":"AQAEnBQAAAAkAAAA…","parts":["dacl"],"propagate":true}
→ {"type":"progress","done":120,"at":"/srv/finance/2025/q3.xlsx"}
→ {"type":"progress","done":480,"at":"/srv/finance/2026/budget"}
← {"type":"stop"}
→ {"type":"applied","done":512,
   "failed":[{"name":"/srv/finance/archive","why":"you are not allowed to"}],
   "stopped":true}

The folder's DACL was applied, and 512 items inside were updated before the walk stopped. The editor shows that, and what could not be done, and stays open though OK was pressed. The person can push the rest with another apply of the same parts.

9.1 Scope and Roles

Peios / Peios technical reference / PSPU / File Choosing

This chapter defines the choosing channel: the interface by which a program has a person choose a file, either an existing one to read or a place and a name to write to, in a dialog drawn by another program, the chooser.

The chooser knows nothing of what the file is for. It is told what to call the dialog, what the file is for in words, where to start, and what kinds of file to offer. What it answers with is a path, or that the person chose nothing.

9.1.1 Roles #

The requester is the program that wants a file. It starts the chooser, says what it wants, and, given a path, opens, reads, creates or writes the file itself.

The chooser shows the person's folders and lets them pick a file or type a name, and answers with the path. It runs as the same person as the requester, and, as a small file manager, lets them make a folder and rename and delete as themselves. It never opens the chosen file, and hands the requester nothing but a path.

The split mirrors §8's. The party that will use the file is the one that opens it, with its own token, so the chooser is trusted with nothing it could misuse, and serves any requester without knowing what files are for.

9.1.2 What this chapter leaves to others #

How the chooser draws its dialog, and on which desktop, is the chooser's own design and that desktop's. On a GXWI desktop the chooser is an app like any other, found by the environment it inherits from the requester.

What the person may do with files and folders is KACS's to decide, as it is for any program of theirs. This chapter says only that the chooser asks the system rather than guessing (§9.6).

What a requester does with the file, and in what format, is its own concern.

9.2 Terminology

Peios / Peios technical reference / PSPU / File Choosing

Requester. The program that wants a file chosen, and which started the chooser. §9.1

Chooser. The program that shows the person's folders and answers with the file they choose. §9.1

Line. One message on the choosing channel: a single compact JSON object, encoded as UTF-8, followed by a line feed (U+000A). §9.3

Request. The first line the requester sends: everything the chooser is told about what is wanted. §9.4

Answer. The one line the chooser sends: the path chosen, or that nothing was. §9.5

Mode. Whether an existing file is to be opened (open) or a place and a name to be saved to (save). §9.4

Filter. A kind of file the requester offers, named for the person and given as a list of extensions. §9.4

9.3 The Channel

Peios / Peios technical reference / PSPU / File Choosing

9.3.1 Starting the chooser #

The requester MUST start the chooser as a child process of its own, with the chooser's standard input and standard output connected to it. It SHOULD start the chooser with its own environment, which is how the chooser finds the desktop to draw on, and the person's home folder.

The chooser's standard error is not part of the channel. The chooser MAY write diagnostics there for an administrator, and the requester MUST NOT read meaning into them.

9.3.2 Lines #

Each direction is a sequence of lines (§9.2): one compact JSON object, UTF-8, then a line feed. A line MUST NOT contain a line feed before its end.

  • Requester to chooser, on the chooser's standard input: the request (§9.4), first and exactly once, and nothing else in this revision.
  • Chooser to requester, on the chooser's standard output: the answer (§9.5), at most once, and nothing else in this revision.

The answer carries a type member naming what it is. The request is identified by being first.

A party MUST ignore a line whose type it does not recognise, and a member of an object it does not recognise (§9.7). A party MAY refuse a line longer than 1 MiB.

9.3.3 Ending #

The chooser ends once it has answered, and when the person closes or cancels the dialog, which it answers as cancelled first. It ends by exiting, which closes its standard output.

The requester ends, or gives up on the chooser, by closing the chooser's standard input. The chooser MUST end promptly when it reads end-of-file there, without answering and without asking the person anything: nobody is left to use the answer. A requester that exits closes the chooser's input whether it means to or not, so the chooser goes with it. The chooser SHOULD also arrange to be signalled when its parent exits (on Linux, PR_SET_PDEATHSIG), and end then.

A requester that reads end-of-file on the chooser's output with no answer MUST treat it as cancelled. It MUST NOT infer anything from the chooser's exit status.

9.4 The Request

Peios / Peios technical reference / PSPU / File Choosing

The request is a JSON object, sent once, as the first line.

MemberTypeRequiredMeaning
modestringyesopen for an existing file to read; save for a place and a name to write to
titlestringyesWhat the dialog is called, for the person: "Export a key"
purposestringnoWhat the file is for, in a sentence, for the person to read
folderstringnoAn absolute path to the folder to start in
namestringnoThe name to suggest, in save mode: "Services.json"
filtersarraynoThe kinds of file to offer, in order, each an object below

Each filter is an object with:

MemberTypeMeaning
namestringWhat the kind is called, for the person: "Registry documents"
extensionsarray of stringsThe extensions it covers, without the dot: ["json"]

9.4.1 What the chooser does with it #

The chooser MUST start in folder if it is given, absolute, and a folder the person may list, and otherwise in the person's home folder.

If filters is given, the chooser MUST offer them in the order given, with the first chosen at first, and MAY offer every file as well. In open mode it SHOULD show only folders and the files of the kind chosen. A filter matches a file by its extension, without regard to case.

In save mode the chooser MUST offer name, if given, as the name to save as, and the person MAY change it. If the person types a name without an extension while a filter is chosen, the chooser SHOULD add the filter's first extension.

The requester MUST NOT rely on the person choosing a file of a kind it offered: the person may choose any file. A requester that needs a kind checks what it opens.

A chooser that receives a request it cannot use, because a required member is missing or mode is unknown, MUST NOT show a dialog, and ends without answering.

9.5 The Answer

Peios / Peios technical reference / PSPU / File Choosing

The chooser sends at most one answer, and ends after sending it.

LineMeaning
{"type":"chosen","path":…}The person chose the file at path, an absolute path
{"type":"cancelled"}The person chose nothing: they cancelled or closed the dialog

9.5.1 What a path promises #

In open mode, the path names a file that existed when the person chose it. The requester opens it, and MUST handle its having gone, or changed, since.

In save mode, the path names a place the person chose to write to, in a folder that existed when they chose it. If a file was there, the person has already said to save over it: the chooser MUST ask before it answers with the path of a file that exists. The requester creates or replaces the file, and MUST handle being refused, since what the person may do can change, and the chooser could not open the file to find out.

The chooser MUST NOT open, create or change the chosen file to choose it, and MUST NOT answer with a path it has not shown or the person has not typed. In neither mode does a path grant anything: the requester uses it as the person, with its own token.

9.6 The Chooser

Peios / Peios technical reference / PSPU / File Choosing

The chooser shows one folder at a time, and lets the person move among folders. Beyond choosing, it MAY let them make a folder, and rename and delete files and folders: a small file manager, so that a person saving a file can make somewhere to put it.

9.6.1 As the person #

The chooser runs as the same person as the requester, started as its child. Everything it does to files and folders is that person's act, which KACS checks as for any program of theirs. The chooser MUST NOT hold or use any authority beyond theirs.

9.6.2 Asking before offering #

The chooser MUST NOT guess what the person may do from who they are. Before it offers an act, it SHOULD find out whether the person may do it, by asking the system: an AccessCheck of the person's token against the descriptor of the folder or the file. What the person may not do it SHOULD show disabled, with the reason in words, rather than hide it or offer it to fail. Where the descriptor cannot be read, it MAY offer the act and let the system decide.

A folder the person may not list MUST be shown as such, with the reason, and not as an empty folder.

9.6.3 Deleting #

The chooser MUST ask the person before it deletes anything, and say when a folder will be deleted with everything in it.

9.7 Extension

Peios / Peios technical reference / PSPU / File Choosing

The choosing channel carries no version number. It grows by addition, under these rules.

9.7.1 What may be added #

A member of the request or of the answer. A party MUST ignore a member it does not recognise, so a requester MAY send one and MUST NOT depend on its effect.

A line type from the chooser that the requester may ignore and still conform: a requester MUST ignore a type it does not recognise (§9.3), and goes on waiting for the answer.

9.7.2 What may not be added without a version #

A change to the meaning of an existing member or line. A mode beyond open and save. A second answer, or a line from the requester after the request.

9.7.3 What may never be added #

A way for the chooser to hand over a file. Nothing on the channel may carry an open file, its contents, or anything but a path from the chooser to the requester. The requester opening what it is pointed at, as the person, is what lets any program use a chooser it does not trust.

9.8 Conformance

Peios / Peios technical reference / PSPU / File Choosing

9.8.1 A conforming requester #

Starting. Starts the chooser as its own child with the chooser's standard input and output connected to it, and sends the request as the first line (§9.3).

The request. Sends mode and title, a folder that is absolute if any, and filters with extensions without the dot (§9.4).

The answer. Uses a chosen path as the person, opening the file itself, and handles its having gone or the act being refused. Treats end-of-file without an answer as cancelled (§9.3, §9.5). Infers nothing from the chooser's exit status or standard error, and does not rely on the person choosing a file of a kind offered (§9.4).

Lines. Ignores a line type and a member it does not recognise (§9.3).

9.8.2 A conforming chooser #

Starting. Shows no dialog for a request it cannot use, and ends (§9.4). Starts in the folder given, or the person's home (§9.4).

Choosing. Offers the filters in order, the first chosen (§9.4). In save mode, asks before answering with a file that exists (§9.5). Never opens, creates or changes the chosen file to choose it (§9.5).

The answer. Sends at most one, a chosen with an absolute path or a cancelled, and ends. Answers cancelled when the person closes or cancels the dialog (§9.3, §9.5).

Ending. Ends promptly, without answering or asking, when its standard input closes (§9.3).

As the person. Acts on files and folders only as the person, and holds nothing beyond their authority. Asks the system what they may do before offering it, and shows what they may not, with the reason. Shows a folder they may not list as such. Asks before deleting (§9.6).

Lines. Ignores a member it does not recognise (§9.3).

Appendix 9.A Message Reference

Peios / Peios technical reference / PSPU / File Choosing

DirectionLineDefined in
Requester → chooserThe request (first line, no type)§9.4
Chooser → requester{"type":"chosen","path":…}§9.5
Chooser → requester{"type":"cancelled"}§9.5

9.A.1 An example #

A registry editor exports a key. The person goes into a folder, makes a folder called Backups inside it, opens it, keeps the suggested name and saves. Lines are shown on several lines here for reading. On the channel each is one line.

→ {"mode":"save","title":"Export Services",
   "purpose":"The key and everything under it is written to a registry document.",
   "folder":"/home/dana","name":"Services.json",
   "filters":[{"name":"Registry documents","extensions":["json"]}]}
← {"type":"chosen","path":"/home/dana/Backups/Services.json"}

The chooser ends, and the registry editor writes the file.

10.1 Scope and Roles

Peios / Peios technical reference / PSPU / Local Principal Administration

This chapter specifies PLPS, the protocol on which a program administers a local principal store: the users and groups a machine holds for itself, their memberships, their credentials and their claims. It is spoken to the daemon that owns the store, never to the store's file.

Two roles participate.

The store daemon owns the store. It is the store's only reader and only writer, it decides who may administer it, and it applies each request or refuses it. On Peios it is lpsd, which is also the machine's local principal source on PSI (§2): the store it administers here is the one it answers for there.

The administrator client is a program that asks: a command-line tool such as lps, a desktop app such as Principals Manager, or a provisioning tool. It holds no state of its own and no privilege of its own; everything it does is a request the store daemon decides whether to honour. Any program MAY be an administrator client, and a third party writing one is the case this chapter is written for.

10.1.1 Why a daemon, and not the file #

The store holds every local principal's credential verifier, so its descriptor admits the store daemon and nothing else. A tool that edited the file would need that descriptor widened to whatever it runs as, undoing the one thing it exists to do. Going through the daemon keeps one writer, makes "an administrator may do this" a check on the caller's token rather than a file permission, and lets the daemon validate every change before anything is written.

This chapter covers:

  • the channel: where it is, who may connect, and the one exchange each connection carries (§10.3)
  • message framing, shared with PGSS Logon (§10.4)
  • the requests: principals (§10.5), groups and membership (§10.6), claims (§10.7), and SSH keys and credential policy (§10.8)
  • the replies, and how a request is refused (§10.9)
  • the obligations binding on each role (§10.10)
  • the self socket, on which any principal reads their own account and sets their own display name (§10.11)

This chapter does not cover:

  • How the store is kept on disk, or how a credential verifier is derived — the store daemon's own design.
  • Signing in, and a principal changing their own password or adding or removing their own SSH keys. Those are PGSS Logon (PGSS §2.20 and PGSS §2.23), and reach the store through PSI (§2.21, §2.23).
  • Looking other principals up. Anyone may ask who a name or a SID is, and who is in a group, on the authority's identity socket (PGSS §2.14); a client that only reads about principals in general SHOULD read there rather than on the admin socket, since that socket answers everyone. A principal reading their own account, including what the identity socket does not carry — their credential policy and SSH keys — reads it on the self socket (§10.11).
  • What policy applies to a principal at sign-in, such as its privileges. That is the authority's (PGSS §2.13).

10.2 Terminology

Peios / Peios technical reference / PSPU / Local Principal Administration

Store. The set of local principals and local groups one store daemon holds, with their memberships, credentials, claims and SSH keys.

Principal. A user or service account the store holds. It has a name, unique in the store and matched without regard to case, and a RID (relative identifier): the last sub-authority of its SID. A RID is never reissued, so a deleted principal's SID never names anyone else.

Domain SID. The SID every principal and local group of the store is under, S-1-5-21-A-B-C. A principal's SID is the domain SID followed by its RID.

Local group. A group the store holds, named and numbered as a principal is, from the same RID space.

Well-known group. A group whose SID is fixed by the system rather than issued by any store: Everyone, Authenticated Users, and the BUILTIN groups such as Administrators. The store holds none of them as objects, but it records memberships of the BUILTIN groups (§2.19).

Group name, as written. Where a request names a group (§10.6), the store daemon resolves it, never the client: a well-known group by its name (Administrators, with or without BUILTIN\), a local group by its name, or any group by its SID written out (S-1-5-32-544). A client MUST pass what the person wrote, and MUST NOT resolve it itself; a client with its own table of well-known names would be a second implementation of the machine's identity scheme, free to disagree with the first.

Effective Unix ID. The POSIX identifier a principal or group projects to: the authority's base for the store's range plus the store's relative number (§2.20). Replies here carry the effective number, so that what an administrator is shown is what the principal will appear as. Zero means the store daemon cannot say.

Credential policy. Which credentials a principal may sign in with (§10.8): a password, an SSH public key, either, none required, or none accepted.

10.3 The Channel

Peios / Peios technical reference / PSPU / Local Principal Administration

10.3.1 The socket #

The store daemon listens on a Unix stream socket. On Peios it is /run/lpsd/admin.sock.

The socket's descriptor SHOULD admit exactly those the store daemon would (§10.3, Who may administer), so that a caller it would refuse is refused at connect rather than after sending a request. That descriptor is load-bearing rather than a convenience: the store daemon reads the caller's token from the connection, and a socket open to everyone would let anyone hold a connection open against its timeouts.

A client MUST treat a connect refused for permission as the store daemon refusing it, exactly as Denied (§10.9).

10.3.2 One request a connection #

Each connection carries one exchange: the client sends one request, the store daemon sends one reply, and the connection is finished. There is no pipelining, no conversation identifier and nothing to demultiplex. The store daemon MUST close the connection after replying, and MUST NOT read a second request from it.

A client MUST open a connection for each request. It MUST NOT hold one open waiting to use it later: the store daemon closes a connection that sends nothing (below).

Every request is answered exactly once, with its own reply (§10.A) or with Failed (§10.9). A client MUST treat a connection closed without a reply as the request's outcome being unknown: a change MAY have been applied. It SHOULD read the store again before telling a person the change failed.

10.3.3 Who may administer #

The store daemon decides who may administer the store from the caller's token, read from the connection, and it MUST decide before reading the request. On Peios a caller may administer the store if its token's user is SYSTEM, or if it carries BUILTIN\Administrators enabled. A group present but deny-only grants nothing, and MUST NOT be treated as membership.

SYSTEM is admitted because a machine's first account is created by a boot-time service, before any person exists to be an administrator.

There is nothing finer. A caller that may administer the store may make every request on this socket; one that may not, may make none. In particular a principal may not change their own profile here. What a principal may do about their own account is served elsewhere: their display name and a view of their account on the self socket (§10.11), and their password and SSH keys through PGSS Logon (PGSS §2.20 and PGSS §2.23).

A store daemon refusing a caller MUST reply Failed with Denied and then close the connection.

10.3.4 Timeouts #

A store daemon SHOULD bound how long it waits for the request and how long it spends sending the reply. On Peios each is five seconds.

A client SHOULD wait for a reply far longer than that. A request that sets a password makes the store daemon derive a verifier, and a store daemon that also answers PSI may serve a sign-in first; on Peios lps waits thirty seconds.

10.3.5 Credentials on the wire #

Add and SetPassword (§10.5) carry a password in the clear. On a kernel-mediated local socket, anything able to read those bytes could as well read the memory of the process that typed them; what must never travel is anything a verifier could be recomputed from, and nothing here does. Both parties SHOULD wipe a buffer that held a password once they are finished with it.

10.4 Message Framing

Peios / Peios technical reference / PSPU / Local Principal Administration

10.4.1 Header #

Every message begins with PGSS Logon's 12-byte header (PGSS §2.6), unchanged, with this chapter's magic:

OffsetSizeFieldValue
04magicPLPS (50 4c 50 53)
42version1
62msg_typeSee §10.A
84total_lenHeader plus body, in bytes

The magic MUST be checked on every message, and MUST end the exchange when wrong. PLPS shares PGSS Logon's codec and some of its structures, so a socket plugged into the wrong daemon would otherwise decode several fields before going wrong.

The high bit of msg_type marks a message sent by the store daemon, which is the authority for its own store. A party MUST check the type it received against the one it expected, not only against that bit.

10.4.2 Size limit #

A message MUST NOT exceed 2 MiB (2,097,152 bytes) in total, larger than PGSS Logon's limit so that Principals can carry a whole store's listing (§10.5). A party MUST reject a header declaring more without reading the body.

10.4.3 Encoding #

Integers, strings, byte strings, arrays and length-framed structures are encoded exactly as PGSS §2.6 specifies, and a body is one length-framed structure. The two length mechanisms are the ones that section warns against confusing: a string or byte string is a u32 byte count and its bytes; a structure or array element is a u32 byte count of the whole structure, and a decoder skips to that end after the fields it knows.

Booleans are a u8: zero is false, anything else is true.

SIDs are byte strings carrying the binary SID (PCDS §4), at most 68 bytes.

An optional string is a u8 presence flag followed by a string: the string is meaningful only where the flag is non-zero. It exists where "leave this alone" and "set this to empty" are both requests a client makes, and PGSS's empty-means-absent rule could not tell them apart.

A claim is encoded as PSI encodes one (§2.13): a length-framed structure of name, flags, value_type and values. Its value types and flags are PCDS §5.9's, and are closed here as they are on PSI (§2.A).

10.4.4 Extending #

The rules of PGSS §2.6 bind this chapter: fields are appended only, an appended field is optional with a stated default, and a new value in an enumeration is a breaking change requiring a version bump.

A decoder that reaches the end of a structure where an appended field would have been MUST substitute that field's default. This chapter states each default where the field is defined; a client and a store daemon of different ages then degrade rather than fail.

10.5 Principals

Peios / Peios technical reference / PSPU / Local Principal Administration

Every request naming a principal names it by name, a string of at most 256 bytes, matched without regard to case. A store daemon MUST answer NotFound (§10.9) for a name it holds no principal by.

10.5.1 List #

msg_type = 0x0001. Empty body. Answered with Principals.

10.5.2 Principals #

msg_type = 0x8001. Every principal in the store, as an array of at most 4096 length-framed entries:

FieldEncodingDefault if absent
namestring, 256 bytes
ridu32
enabledboolean
groupsu32: how many groups it is listed in
unix_idu32: its effective Unix ID (§10.2)0

groups counts listed memberships only, not the primary group.

10.5.3 Show #

msg_type = 0x0002. Body: name. Answered with Principal.

10.5.4 Principal #

msg_type = 0x8002. One principal in full:

FieldEncodingDefault if absent
namestring, 256 bytes
ridu32
enabledboolean
sidSID
groupsarray of at most 128 group references
unix_idu32: effective0
primary_groupone length-framed group referencean empty reference
homestring, 4096 bytesempty
shellstring, 4096 bytesempty
display_namestring, 256 bytesempty
claimsarray of at most 64 claims (§10.4)none
permitted_logon_typesu32, as on Add0, not stated
credential_policyu8 (§10.8)not said

credential_policy is closed, as §10.8 says: a client MUST treat a value it does not know as a reply it cannot read. A client given no credential_policy MUST NOT show one; the store daemon did not say.

A group reference is a length-framed structure:

FieldEncodingDefault if absent
sidSID
namestring, 256 bytes: what this machine calls the groupempty
unix_idu32: its effective Unix ID, or zero0

The store daemon names each group, a well-known one included, and gives its number where it knows it, so a client never builds a SID or resolves a name itself. An empty name means the store daemon knows no name for the SID; zero means it cannot say the number.

10.5.5 Domain #

msg_type = 0x0003. Empty body. Answered with DomainIs.

10.5.6 DomainIs #

msg_type = 0x8003. Body: sid, the store's domain SID (§10.2).

Domain is the smallest request there is, changes nothing, and is refused to anyone who may not administer the store (§10.3). A client MAY send it to find out whether its caller may administer the store, before offering to change anything.

10.5.7 Add #

msg_type = 0x0004. Creates a principal. Answered with Created carrying its RID.

FieldEncodingDefault if absent
namestring, 256 bytes
credential_kindu8: 0 none, 1 a password
secretbyte string, 4096 bytes
enabledboolean
groupsarray of at most 128 length-framed strings: groups as written (§10.2)
permitted_logon_typesu320, not stated
primary_groupoptional string (§10.4), 256 bytes: a group as writtenabsent
homeoptional string, 4096 bytesabsent
shelloptional string, 4096 bytesabsent
display_nameoptional string, 256 bytesabsent

credential_kind says plainly whether the principal has a password, rather than an empty secret meaning none, so that a client cannot make a principal that needs no credential by accident. secret is present either way, and empty for 0. A store daemon MUST refuse an empty secret with kind 1 as Invalid, and MUST refuse a kind it does not know.

A principal made with kind 0 has the credential policy NoCredential (§10.8): it signs in with nothing collected. One made with kind 1 has the policy Password.

permitted_logon_types is the set of PGSS §2.16's, as PSI carries it (§2.13). Zero is not stated, which the authority reads as its default: every kind of sign-in a person could use, and never a service sign-in.

The principal gets the store's next RID. Where primary_group, home, shell or display_name is absent, it gets what the store daemon chooses (on Peios, Authenticated Users, /home/<name>, /bin/sh and no display name). A present field is checked as SetProfile and SetPrimaryGroup check it, and a store daemon MUST refuse the whole request if it refuses any field, creating nothing: a principal is made whole or not at all.

A store daemon that predates the last four fields ignores them, and makes the principal with its own choices. A client cannot tell that from the reply; one that must know reads the principal back with Show.

A store daemon MUST refuse a name it already holds, a principal's or a local group's, as Exists, and a name it will not accept as Invalid with the reason. Which names it accepts is its own. On Peios a name is 1 to 256 bytes of printable ASCII, may not contain @ \ / : ,, and may not be a well-known group's name.

10.5.8 Remove #

msg_type = 0x0005. Body: name. Deletes the principal. Answered with Done.

Its RID is not reissued, so its SID keeps appearing in the descriptors of whatever it owned, naming nobody. A client SHOULD say so before removing one, and SHOULD offer disabling instead.

10.5.9 SetEnabled #

msg_type = 0x0006. Body: name, then enabled, a boolean. Answered with Done. A disabled principal keeps everything, and cannot sign in.

10.5.10 SetPassword #

msg_type = 0x0007. Body: name, then secret, a byte string of at most 4096 bytes, never empty. Sets the principal's password. Answered with Done.

It does not change the principal's credential policy (§10.8). A principal whose policy accepts no password is not given one by this request: a client that means the password to be used MUST also send CredentialPolicy.

10.5.11 SetProfile #

msg_type = 0x000d. Changes part of a principal's profile, leaving the rest alone. Answered with Done.

FieldEncoding
namestring, 256 bytes
homeoptional string (§10.4), 4096 bytes
shelloptional string, 4096 bytes
display_nameoptional string, 256 bytes

An absent field is left as it is. A present empty display_name clears it. A store daemon MAY refuse a value it will not accept as Invalid; on Peios home and shell must be absolute paths.

10.5.12 Rename #

msg_type = 0x0015. Changes what a principal is called. Answered with Done.

FieldEncoding
namestring, 256 bytes: the principal
new_namestring, 256 bytes: what it is to be called

Its SID, RID and Unix ID stay, so everything that names it by SID, every descriptor and every token already minted, still does. Its home directory stays too: a store daemon MUST NOT change home on a rename. A client that means the home directory to follow sends SetProfile as well.

A store daemon MUST refuse a new_name another principal or a local group holds as Exists, and applies the rules for a name as Add does. A new_name differing from the principal's name only in case is a rename; one equal to it is Done and changes nothing.

10.5.13 SetLogonTypes #

msg_type = 0x0016. Sets which kinds of sign-in a principal may be used for. Answered with Done.

FieldEncoding
namestring, 256 bytes
permitted_logon_typesu32, as on Add

Zero returns the principal to not stated, the authority's default. There is no value meaning "no sign-in at all"; a client disables the principal for that. A store daemon MUST keep bits for logon types it does not know as they are given, as it does on Add: the authority checks the types it knows.

10.5.14 Last administrator #

A store daemon MUST refuse, as Invalid, a request that would leave the store with no enabled principal in BUILTIN\Administrators who can sign in: removing or disabling the last one, removing the last one from that group (§10.6), setting the last one's permitted_logon_types to a set that permits none of interactive, remote interactive or network sign-in, or leaving the last one nothing to sign in with (§10.8): a CredentialPolicy it would not have something to sign in with under, or a KeyRemove of the last key it signs in with. A machine left so has nobody to administer it.

10.6 Groups and Membership

Peios / Peios technical reference / PSPU / Local Principal Administration

10.6.1 GroupList #

msg_type = 0x000a. Empty body. Answered with Groups.

10.6.2 Groups #

msg_type = 0x8007. Every local group in the store, as an array of at most 4096 length-framed entries:

FieldEncodingDefault if absent
namestring, 256 bytes
ridu32
unix_idu32: its effective Unix ID (§10.2)
sidSID
membersu32: how many principals list it
descriptionstring, 1024 bytesempty

The well-known groups are not in it: the store holds none of them as objects (§10.2). members counts listed memberships, not principals whose primary group it is; the identity socket's MEMBERS counts both (PGSS §2.16).

A group's description says what it is for, on one line, for a person to read; empty is none. Anyone may read it, on the identity socket's DESCRIPTION (PGSS §2.16), where a store daemon that is a principal source answers it.

10.6.3 GroupCreate #

msg_type = 0x000b. Creates a local group, with the store's next RID. Answered with Created carrying it.

FieldEncodingDefault if absent
namestring, 256 bytes
descriptionstring, 1024 bytesempty

A store daemon MUST refuse a name it already holds, a principal's or a group's, as Exists, and applies the same rules to a group's name as to a principal's (§10.5). It MAY refuse a description it will not keep as Invalid; on Peios one may hold no control characters, and is kept with the spaces at its ends removed.

A store daemon that predates description ignores it, and makes the group with none, as Add describes (§10.5).

10.6.4 GroupRename #

msg_type = 0x0017. Body: name, a local group's, then new_name, as Rename (§10.5). Changes what the group is called. Answered with Done.

Its SID, RID and Unix ID stay, and so does every membership of it, since a membership names the group by SID. A store daemon MUST answer NotFound where it holds no local group by name, and refuses new_name as Rename does.

10.6.5 GroupDescribe #

msg_type = 0x0018. Sets a local group's description. Answered with Done.

FieldEncoding
namestring, 256 bytes: a local group's
descriptionstring, 1024 bytes; empty clears it

A store daemon MUST answer NotFound where it holds no local group by name. The well-known groups' descriptions are the authority's, and cannot be set here.

10.6.6 GroupDelete #

msg_type = 0x000c. Body: name, a local group's. Deletes it. Answered with Done.

A store daemon MUST refuse, as Invalid, deleting a group any principal lists or has as its primary group: a principal would be left in a group that no longer exists. A client removes the members first.

A store daemon MUST answer NotFound where it holds no local group by name, a well-known group's name included.

10.6.7 GroupAdd and GroupRemove #

GroupAdd, msg_type = 0x0008, puts a principal in a group; GroupRemove, msg_type = 0x0009, takes it out. Both are answered with Done.

FieldEncoding
namestring, 256 bytes: the principal
groupstring, 256 bytes: the group as written (§10.2)

name MUST name a principal: groups do not nest, and a store daemon MUST answer NotFound for a group's name there.

group MAY name a local group, a BUILTIN group, or any group by its SID. A membership of a BUILTIN group is recorded here on the machine's behalf, and asserted at sign-in under PSI's membership-scope exception (§2.19); it is how a principal becomes an administrator. A membership of a group in another domain is recorded as given.

Both are idempotent: adding a membership already held, or removing one not held, is Done and changes nothing. A principal MAY be listed in at most 128 groups; a store daemon MUST refuse a 129th as Invalid.

Removing a principal from BUILTIN\Administrators is refused if it is the last enabled one there (§10.5).

A store daemon MUST answer NotFound for a group that names nothing it can resolve.

10.6.8 SetPrimaryGroup #

msg_type = 0x000e. Body: as GroupAdd. Makes group the principal's primary group: the one it projects to as its POSIX group ID. Answered with Done.

A primary group is a membership (§2.13): the principal is in it whether or not it is also listed.

10.7 Claims

Peios / Peios technical reference / PSPU / Local Principal Administration

A principal's claims are named, typed attributes that the authority puts on the tokens it mints for them, and that conditional ACEs are evaluated against (PCDS §5.9). Principal carries them (§10.5).

10.7.1 SetClaim #

msg_type = 0x000f. Sets a claim, replacing one of the same name. Answered with Done.

FieldEncoding
namestring, 256 bytes: the principal
claimone claim (§10.4)

Claim names match without regard to case. A store daemon MUST refuse, as Invalid, a claim PSI could not carry to the authority (§2.13): a name empty or longer than 255 bytes or containing a NUL, a flag or value type it does not recognise, more than 64 values, or a string or octet value longer than 1024 bytes. It MUST refuse a 65th claim on one principal.

10.7.2 RemoveClaim #

msg_type = 0x0010. Removes a claim. Answered with Done.

FieldEncoding
namestring, 256 bytes: the principal
claim_namestring, 255 bytes

Removing a claim the principal does not hold is Done and changes nothing.

10.8 Keys and Credential Policy

Peios / Peios technical reference / PSPU / Local Principal Administration

A principal's credential policy says which credentials it may sign in with. Its SSH public keys are what it may sign in with by key (PGSS §2.D). The two are separate on purpose: adding a key does not let anyone sign in with it until the policy says keys are accepted, so enrolling a key and allowing key sign-in are two decisions, and neither happens as a side effect of the other.

10.8.1 Credential policies #

ValuePolicySigns in with
0Deniednothing: every sign-in is refused
1Passwordits password
2SshPublicKeyone of its SSH keys
3PasswordOrKeyeither
4NoCredentialnothing collected (§10.5, Add)

The enumeration is closed (§10.4). A store daemon MUST refuse a value it does not know.

10.8.2 KeyList #

msg_type = 0x0011. Body: name. Answered with Keys.

10.8.3 Keys #

msg_type = 0x8008. The principal's credential policy and keys:

FieldEncoding
policyu8
keysarray of at most 32 length-framed keys

A key:

FieldEncoding
idbyte string, 16 bytes: the store daemon's, never all zero
fingerprintstring, 128 bytes: as ssh-keygen -l writes one
labelstring, 128 bytes
createdu64: seconds since the Unix epoch

Detail (§10.5) answers the credential policy too, as a trailing optional field; KeyList answers it from every store daemon.

10.8.4 KeyAdd #

msg_type = 0x0012. Enrols an SSH public key. Answered with Done.

FieldEncoding
namestring, 256 bytes
public_keystring, 16384 bytes: one line of an OpenSSH authorized_keys file
labelstring, 128 bytes, no control characters

A store daemon MUST refuse, as Invalid, a key it cannot read or does not support, a key the principal already has, a 33rd key, and a label too long or carrying a control character. It does not change the policy.

10.8.5 KeyRemove #

msg_type = 0x0013. Body: name, then id, a byte string of 16 bytes. Removes the key. Answered with Done, or NotFound for an id the principal has no key by. It is refused as the last-administrator rule says (§10.5).

10.8.6 CredentialPolicy #

msg_type = 0x0014. Body: name, then policy, a u8. Sets the principal's credential policy. Answered with Done. It is refused as the last-administrator rule says (§10.5).

A principal has something to sign in with when its policy is NoCredential; Password and it has a password; SshPublicKey and it has a key; or PasswordOrKey and it has either. Denied never does.

10.9 Replies and Refusal

Peios / Peios technical reference / PSPU / Local Principal Administration

Every request has exactly one successful reply, listed in §10.A, and any request may instead be answered with Failed.

10.9.1 Created #

msg_type = 0x8004. Body: rid, a u32. Answers Add and GroupCreate: the two requests that allocate a RID, and so have something to report.

10.9.2 Done #

msg_type = 0x8005. Empty body. Answers every other request that changes the store.

10.9.3 Failed #

msg_type = 0x8006. The request was refused.

FieldEncoding
failureu32: why, from the table below
reasonstring, 512 bytes: why, in words
ValueFailureMeans
1DeniedThe caller may not administer the store (§10.3).
2NotFoundNothing in the store by the name, or the key, given.
3ExistsThe name asked for is already held.
4InvalidWell-formed, and asking for something the store will not do.
5InternalThe store could not be read or written.

The enumeration is closed (§10.4). failure is for a client that branches on it; reason is for a person, and a client SHOULD show it as it is rather than interpret it.

A store daemon MUST answer a request it cannot decode, or whose msg_type this chapter does not define, with Invalid.

There is no failure for "you may not see that". Administering is not authenticating, and a caller already allowed to administer the store has nothing to learn from a refusal that it could not learn by listing.

10.9.4 A change is applied whole, or not at all #

A store daemon MUST NOT acknowledge a change, with Created or Done, before it has been made durable. A change that cannot be saved MUST be undone and answered Internal; a refused request MUST leave the store as it was.

Every change acknowledged is also announced to the authority, as PSI's Changed (§2.17), so that what it answers next reflects it. A client here is told nothing when somebody else changes the store: one that shows the store SHOULD read it again after its own changes and from time to time.

10.10 Conformance

Peios / Peios technical reference / PSPU / Local Principal Administration

10.10.1 A conforming store daemon #

The channel. Decides from the caller's token, before reading the request, whether the caller may administer the store, admitting SYSTEM and a token carrying BUILTIN\Administrators enabled, and treating a deny-only group as no membership; refuses anyone else with Denied (§10.3). Serves one request a connection, answers it exactly once, and closes (§10.3).

Framing. Checks the magic and the version of every message, refuses one over 2 MiB unread, and substitutes the stated default for an appended field a peer did not send (§10.4).

Requests. Answers each with its own reply (§10.A) or Failed, and a request it cannot decode or does not define with Invalid (§10.9). Resolves a group as written itself (§10.2). Answers NotFound for a principal, group or key it does not hold, Exists for a name already held, and Invalid, with the reason, for anything it will not do (§10.5 to §10.9).

Its rules. Refuses to leave the store with no enabled administrator who can sign in (§10.5); to delete a group anyone is in or has as their primary group (§10.6); a claim PSI could not carry (§10.7); and a key it cannot read, or holds already (§10.8). Gives a principal made without a password the policy NoCredential, and one made with one Password (§10.5). Makes a principal whole or not at all, refusing an Add if it refuses any field of it (§10.5). Keeps a renamed principal's SID, RID, Unix ID and home directory (§10.5), and a renamed group's SID, RID, Unix ID and memberships (§10.6). Holds one name for one principal or local group, matched without regard to case (§10.5, §10.6). Never reissues a RID (§10.2).

Changes. Acknowledges a change only once it is durable, undoes one it cannot save, leaves the store untouched on a refusal, and announces every change to the authority (§10.9).

10.10.2 A conforming administrator client #

The channel. Opens a connection for each request and holds none open (§10.3). Treats a connection refused for permission as Denied, and a connection closed without a reply as an outcome unknown, reading the store again before saying the change failed (§10.3).

Requests. Passes a group as the person wrote it, resolving nothing itself (§10.2). Sends credential_kind 0 only where a principal is meant to need no credential (§10.5). Sends CredentialPolicy alongside SetPassword where the password is meant to be used (§10.5). Reads a principal back with Show where it must know that an Add's profile was applied (§10.5). Shows no credential policy where the store daemon gave none (§10.5).

Refusals. Shows a refusal's reason as it is (§10.9).

Reading. Reads principals for display on the identity socket where it need not administer them (§10.1), and reads the store again after its own changes, since nothing tells it of anyone else's (§10.9).

10.10.3 The self socket #

The obligations of a store daemon serving the self socket, and of its clients, are collected in §10.11.

10.11 The Self Socket

Peios / Peios technical reference / PSPU / Local Principal Administration

The admin socket (§10.3) is for administrators, and every request on it names the principal it is about. A principal also needs to see their own account — what they are called, what they sign in with, which SSH keys they have — and to set what they are called, without being an administrator. The self socket serves that: any authenticated principal may connect to it, and no request on it names anybody.

Changing a credential is not here. A display name is not a credential, so setting it asks for no proof; a password or an SSH key is one, so changing either goes through PGSS Logon, where the principal's current password is asked for (PGSS §2.20 and PGSS §2.23).

10.11.1 The socket #

The store daemon listens on a second Unix stream socket. On Peios it is /run/lpsd/self.sock.

Its descriptor MUST admit every authenticated principal to connect. On Peios it is D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x100082;;;AU): SYSTEM and Administrators with full access, and Authenticated Users with exactly what a connect needs (FILE_WRITE_DATA, READ_ATTRIBUTES and SYNCHRONIZE), the mask Peios grants them on /run/logon.sock. The store daemon sets it when it creates the socket and reads it back; the socket's directory admits nobody the socket is for, so a socket that inherited it would be closed to everyone it exists to serve.

10.11.2 The subject is the peer #

The subject of every request is the user of the connected peer's token, read by the store daemon from the connection when it accepts it. No request carries a name, a SID or any other way to point at a principal, and the store daemon MUST NOT answer about, or change, any principal but that one.

A peer whose user the store does not hold — SYSTEM, a service identity, a principal another source holds — is refused with NotFound and a reason saying so. It is never shown, or allowed to change, somebody else's account.

10.11.3 Framing #

Messages are PLPS's (§10.4): the same header, magic, encoding and Failed and Done replies (§10.9). The requests are in a range of their own, 0x0040 upward, with their reply at 0x8040. Each socket MUST refuse the other's requests with Invalid: the self socket is not a second way to administer the store, and the admin socket does not answer about its caller.

A request on the self socket MUST NOT exceed 4096 bytes. The store daemon MUST refuse, from its header and without reading the body, one that declares more, answering Invalid. Replies are bounded by PLPS's ceiling as on the admin socket.

10.11.4 One request a connection, never served blocking #

Each connection carries one exchange, as on the admin socket (§10.3).

The admin socket admits only administrators, so a store daemon MAY serve it a connection at a time. The self socket admits everyone, and a store daemon that also answers PSI (§2) MUST NOT let a connection on it delay anything else: a principal who connected and sent nothing would otherwise stall every sign-in behind them. A store daemon MUST serve the self socket without blocking on any one connection, MUST bound how long a connection may remain open from accept to the last byte of its reply, and MUST bound the connections open at once and the connections any one user, by the token's user SID, holds. A caller over a bound is refused, with Failed where that will not block, and the connection closed.

On Peios lpsd holds each connection non-blocking in a table its poll loop drives, and drops one at 5 seconds after accept. It holds at most 32 at once and 4 from any one user.

10.11.5 ShowSelf #

msg_type = 0x0040. Empty body. Answered with Self.

10.11.6 Self #

msg_type = 0x8040. The caller's own account.

FieldEncoding
namestring, 256 bytes
sidbyte string, 68 bytes
display_namestring, 256 bytes; empty when unset
enabledboolean
policyu8: the credential policy (§10.8)
has_passwordboolean: whether the account has a password at all
keysarray of at most 32 length-framed keys

A key:

FieldEncoding
idbyte string, 16 bytes: as Keys gives it (§10.8)
fingerprintstring, 128 bytes: as ssh-keygen -l writes one
algorithmstring, 64 bytes: the key's type, such as ssh-ed25519
labelstring, 128 bytes
createdu64: seconds since the Unix epoch

has_password is separate from policy because a policy can name a password the account has not got, and a principal adding or removing a key must prove a password (PGSS §2.23). A client SHOULD offer key changes only where the account has one.

This is what Show and KeyList give an administrator about the same principal, less what is the administrator's to manage. Nothing in it is a verifier, a key's private half or anything a credential could be recomputed from.

10.11.7 SetDisplayName #

msg_type = 0x0041. Answered with Done.

FieldEncoding
display_namestring, 256 bytes; empty clears it

The store daemon MUST validate it exactly as it validates SetProfile's display_name (§10.5), refusing what that refuses with Invalid. It MUST refuse with Denied a principal that is disabled: a session that outlived its account's disabling is not a way to change the account.

The change is applied as any other (§10.9): made durable before Done, undone and answered Internal if it cannot be saved, and announced to the authority as PSI's Changed (§2.17) so that its identity channel answers with the new name at once. A request that changes nothing — the name is already that — MAY be answered Done without writing.

10.11.8 Conformance #

A store daemon serving the self socket admits every authenticated principal to connect to it; answers every request about the peer's token's user and nobody else, and refuses a peer it does not hold with NotFound; refuses the admin socket's requests with Invalid, and a request over 4096 bytes from its header; never blocks on one connection, and bounds each connection's time, the connections open, and each user's connections; validates SetDisplayName as SetProfile, refuses it for a disabled principal, and makes, undoes and announces it as §10.9 requires.

A client of the self socket names nobody, since there is no field to; opens a connection for each request; treats a connect refused for permission as Denied; and changes credentials through PGSS Logon, not here.

Appendix 10.A Message Reference

Peios / Peios technical reference / PSPU / Local Principal Administration

10.A.1 Messages #

msg_typeMessageDirectionAnswered withDefined in
0x0001Listclient → store daemonPrincipals§10.5
0x0002Showclient → store daemonPrincipal§10.5
0x0003Domainclient → store daemonDomainIs§10.5
0x0004Addclient → store daemonCreated§10.5
0x0005Removeclient → store daemonDone§10.5
0x0006SetEnabledclient → store daemonDone§10.5
0x0007SetPasswordclient → store daemonDone§10.5
0x0008GroupAddclient → store daemonDone§10.6
0x0009GroupRemoveclient → store daemonDone§10.6
0x000aGroupListclient → store daemonGroups§10.6
0x000bGroupCreateclient → store daemonCreated§10.6
0x000cGroupDeleteclient → store daemonDone§10.6
0x000dSetProfileclient → store daemonDone§10.5
0x000eSetPrimaryGroupclient → store daemonDone§10.6
0x000fSetClaimclient → store daemonDone§10.7
0x0010RemoveClaimclient → store daemonDone§10.7
0x0011KeyListclient → store daemonKeys§10.8
0x0012KeyAddclient → store daemonDone§10.8
0x0013KeyRemoveclient → store daemonDone§10.8
0x0014CredentialPolicyclient → store daemonDone§10.8
0x0015Renameclient → store daemonDone§10.5
0x0016SetLogonTypesclient → store daemonDone§10.5
0x0017GroupRenameclient → store daemonDone§10.6
0x0018GroupDescribeclient → store daemonDone§10.6
0x8001Principalsstore daemon → client§10.5
0x8002Principalstore daemon → client§10.5
0x8003DomainIsstore daemon → client§10.5
0x8004Createdstore daemon → client§10.9
0x8005Donestore daemon → client§10.9
0x8006Failedstore daemon → client§10.9
0x8007Groupsstore daemon → client§10.6
0x8008Keysstore daemon → client§10.8

Any request may be answered with Failed instead.

10.A.1.1 On the self socket #

msg_typeMessageDirectionAnswered withDefined in
0x0040ShowSelfclient → store daemonSelf§10.11
0x0041SetDisplayNameclient → store daemonDone§10.11
0x8040Selfstore daemon → client§10.11

Done and Failed (§10.9) answer here too. Each socket refuses the other's requests with Invalid.

10.A.2 Protocol constants #

ConstantValueDefined in
Socket path/run/lpsd/admin.sock on Peios§10.3
Self socket path/run/lpsd/self.sock on Peios§10.11
Largest self-socket request4096 bytes§10.11
MagicPLPS (50 4c 50 53)§10.4
Version1§10.4
Header size12 bytes§10.4
Maximum message size2,097,152 bytes§10.4
Requests a connection1§10.3

10.A.3 Field limits #

FieldLimit
A principal's or group's name256 bytes
A password4096 bytes, never empty
home, shell4096 bytes
display_name256 bytes
A group's description1024 bytes
A principal's listed groups128
Principals in Principals4096
Groups in Groups4096
A SID68 bytes
Claims on a principal64
A claim's name255 bytes
A claim's values64
A string or octet claim value1024 bytes
SSH keys on a principal32
An SSH public key16384 bytes
A key's label128 bytes
A key's fingerprint128 bytes
A key's algorithm64 bytes
A refusal's reason512 bytes

10.A.4 Enumerations #

EnumerationDefined in
Failure§10.9
Credential policy§10.8
Logon typesPGSS §2.16, carried as PSI carries them (§2.13)
Claim value types and flagsPCDS §5.9, closed as on PSI (§2.A)

Peios Learn — documentation for the Peios project.

Built with Trail.