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.
2.2 Terminology
Peios / Peios technical reference / PSPU / Principal Source Interface
Terms defined in the Peios Kernel TRM (token, logon session, privilege), in PCDS (SID, security descriptor, claim attribute) and in PGSS (authority, client, principal, conversation, round, credential material, prompt, derivation) are used here with the same meaning and are not redefined.
Source. A process that is authoritative for some set of principals: it verifies their credentials and says who they are. Called a principal source in full.
Registration. The exchange in which a source announces itself and the authority decides whether to accept it. Precedes any conversation.
Domain. The SID namespace a source is authoritative for. Every principal a source may assert lives under it. See §2.10.
Source conversation. One logon's exchange between the authority and a source, distinguished from other concurrent ones by a conversation identifier. Not to be confused with a PGSS Logon conversation, which is between a client and the authority; one of each exists per logon.
Assertion. A source's terminal message stating who a principal is. The only successful outcome a source can produce.
Originator. The verified identity of the process that requested a logon, as established by the authority from the client's connection. Relayed to the source, which cannot learn it any other way.
Service SID. A SID derived from a service's name, placed in that service's token by the init system, and unforgeable by anything else. How a source's identity is established (§2.9).
Membership scope. The constraint on which groups a source may assert. Separate from identity scope, which constrains whose identity it may assert at all. Sections 2.18 to 2.20 exist because these are different questions with different answers.
Relative identifier. In this chapter, a POSIX identifier as a source states it: an offset within the range the authority assigned that source, never an absolute number (§2.20). Where the SID sense is meant — the last sub-authority of a SID — the text says so.
2.3 Sources Dial In
Peios / Peios technical reference / PSPU / Principal Source Interface
The authority listens. Sources connect to it. The authority never initiates a connection to a source.
This is the most consequential shape decision in this chapter, and it is worth being explicit about what it buys.
2.3.1 Why the direction matters #
The authority holds the privilege to mint tokens. It is the most privileged userspace process on the system. An authority that dialled out would need, in its configuration, a list of paths to connect to — and a process holding that privilege having a configurable list of things to go and talk to is a liability out of proportion to the convenience.
Because sources connect inward, the authority's sockets are
accept()-only. It never opens an outbound connection to anything, for
any reason.
2.3.2 What follows #
Restart is the source's problem. A source whose connection drops reconnects. The authority does not retry, does not queue, and does not track sources it has not heard from. A source that has gone away is simply not registered.
A source is not required to exist. An authority with no registered sources cannot authenticate anybody, and that is a coherent state rather than an error — it means no identity has been made available to it yet.
Ordering is the init system's problem. The authority must be listening before a source can register, and a service that depends on authentication must start after a source has. Expressing that is a service-ordering question, not a protocol one, and this chapter says nothing about it.
2.4 Assert, Never Mint
Peios / Peios technical reference / PSPU / Principal Source Interface
A source says who somebody is. It cannot say anything else, and the protocol is built so that this is structural rather than a rule anyone has to remember.
2.4.1 The success terminal #
PGSS Logon's success terminal is AccessGranted, carrying a session
identifier and a token descriptor. If PSI reused it, sources would be
minting sessions.
PSI's success terminal is Assertion (§2.13), which carries an
identity: a SID, a canonical name, and group memberships. There is no
session identifier and no descriptor to attach a token to. A source
could not mint one if it wanted to, because there is no message in which
to say so.
That is the whole of the mechanism. No capability check, no trust level, no configuration flag — a source cannot mint because the protocol gives it no way to express minting.
2.4.2 What the authority keeps #
Everything else:
- Derivation. What the token actually contains — its privileges, its integrity level, its derived group memberships, its projected identifiers — is the authority's, applying local policy (PGSS §2.1).
- Session creation. The logon session, and the record of which source vouched for it.
- Validation. Every SID a source sends is bytes until the authority has checked it (§2.13).
- Scope enforcement. What a source is permitted to claim (§2.18 to §2.20).
- Peer verification. On every connection it accepts.
- Rate and round limits, and the policing of what a source may ask a client for (§2.12).
2.4.3 Why a compromised source is bounded #
A source that is entirely compromised can lie about the principals in its own domain. It cannot mint a token, cannot elevate anyone's privileges, cannot claim identities outside its domain (§2.18), and — unless configured otherwise — cannot assert memberships outside it either (§2.19).
That bound is the reason for the process boundary. It is not that sources are expected to be malicious; it is that a source is the component parsing credentials from the outside world, and therefore the one most likely to be wrong.
2.5 A Superset of PGSS Logon
Peios / Peios technical reference / PSPU / Principal Source Interface
A principal source is an authentication authority for its slice of the world. The authority that federates is an authority over authorities. Once that is seen, most of PSI writes itself.
2.5.1 The relationship #
PSI's interrogation phase is PGSS Logon's, with identical message
bodies. CredentialRequest and CredentialResponse carry exactly the
bytes PGSS §2.8 defines, and the authority relays them nearly verbatim
in both directions.
The consequences are worth stating plainly:
- The source decides what to ask for. Not the authority. The authority does not know what credentials a source requires, and does not need to.
- The authority becomes a relay in the interrogation phase. It is a shorter path than synthesising its own prompts, not a longer one.
- Adding a credential type is a change to sources, not to the authority and not to clients, which already render what they are given (PGSS §2.3).
- A source could be tested in isolation by pointing a PGSS Logon client at it, for the interrogation phase at least.
2.5.2 Where they diverge, deliberately #
Three differences, each for a stated reason.
The success terminal. Assertion rather than AccessGranted, so
that a source cannot mint. See §2.4. This is the divergence that
matters.
Multiplexing. PGSS Logon is one conversation per connection; the connection is the conversation. PSI carries many concurrent logons over one long-lived connection, so its header adds a conversation identifier (§2.7). The alternative — serialising every logon behind one connection — would make any slow logon a system-wide login stall.
Distinct magic. PPSI rather than PGSL. Two protocols this
similar sharing a codec is a cross-protocol hazard: a socket plugged
into the wrong daemon would partially work, which is far worse than
failing outright. The magic makes it a hard error on byte zero.
The full accounting of what is shared, what is added and what differs is §2.C.
2.5.3 "Just relaying" is loose #
The authority is a relay in the interrogation phase only, and even there it is not passive. It polices what a source may ask a client for (§2.12), it validates what a source asserts (§2.13), and it enforces scope (§2.18 to §2.20). Everything before and after the interrogation is entirely its own.
2.6 The Channel
Peios / Peios technical reference / PSPU / Principal Source Interface
2.6.1 Socket #
An authority that federates over PSI MUST listen on a SOCK_STREAM Unix
domain socket.
Unlike PGSS Logon's path, this one is not normative. PSI is not a
conformance requirement (§2.1), and an authority that offers it may put
it where it likes provided its sources are told. Mainline's is
/run/psi.sock.
2.6.2 Access control #
The socket SHOULD carry a security descriptor. It is DoS protection and nothing more, and an implementation MUST be written as though it were absent.
The reason is that the socket cannot be the boundary. What establishes a source's identity is the peer's token (§2.9), which is checked on every connection. A descriptor that kept casual traffic away would be a convenience; a descriptor relied upon would be a second, weaker access control that someone will eventually assume is doing the work.
An authority MUST therefore bound the number of unregistered connections it will hold open, and the time it will wait for a registration, independently of any descriptor.
2.6.3 Long-lived connections #
A source's connection persists for the life of the source and carries every logon routed to it.
An authority MUST bound the number of registered sources and the number of concurrent conversations per source. A source MUST bound the conversations it will track, and MUST NOT depend on the authority's bookkeeping to do it — a source that trusted the authority's limit would be trusting a bound it cannot verify.
2.6.4 Failure #
A framing error is fatal to the connection, not to a conversation. Once a message has failed to parse there is no way to know where the next one starts, so both parties MUST tear the connection down rather than attempt resynchronisation.
A failed write is likewise fatal. A partial write desynchronises the stream just as a bad frame does, and treating it as a per-conversation error would leave a corrupt connection in use.
Ordinary semantic failures — an unknown principal, a bad credential, a
refused logon — are not connection failures. They are Refusal
messages (§2.13) and the connection continues.
2.7 Message Framing
Peios / Peios technical reference / PSPU / Principal Source Interface
2.7.1 Header #
Every message begins with a 20-byte header:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | magic | PPSI (50 50 53 49) |
| 4 | 2 | version | 1 |
| 6 | 2 | msg_type | See §2.A |
| 8 | 4 | total_len | Header plus body, in bytes |
| 12 | 8 | conversation | See below |
The first twelve bytes are PGSS Logon's header, unchanged and at the
same offsets. total_len in particular sits where PGSS §2.6 puts it,
which is what lets one transport implementation frame either protocol
off a stream.
2.7.2 Magic #
PPSI, checked on every message, fatal to the connection when wrong.
This matters more here than it would for an unrelated protocol, because
PSI and PGSS Logon share message bodies (§2.5). A
CredentialRequest from one is byte-identical to the other's. Without
distinct magic, a socket plugged into the wrong daemon would decode
several fields correctly before going wrong — which is the failure mode
hardest to diagnose and easiest to miss.
2.7.3 Conversation identifier #
The conversation field distinguishes concurrent logons on one
connection.
- Conversation
0is reserved for connection-level messages:Register,RegisteredandChanged(§2.17). It MUST NOT be used for a logon or a query. - Logon and query conversations use identifiers from 1 upward, drawn from one space.
- The authority allocates them. A source MUST NOT invent one, and MUST reply on the identifier it was given.
- An identifier is unique among live conversations on one connection. An authority MAY reuse one after a conversation has reached a terminal state.
A source MUST reject a message on a conversation it does not know, and
MUST NOT treat it as opening a new one. Only Authenticate (§2.11),
Query (§2.15) and EnumerateSource (§2.16) open one, and an authority
MUST NOT open one with an identifier already live.
Rejecting means declining to act on it. A source MUST NOT reply on a conversation it does not know: an authority MAY have reused the identifier after a terminal state, so a reply could arrive as a second terminal message for a conversation that has already ended. Discarding it, and recording that it happened, is the whole of the obligation.
A source MUST likewise refuse an Authenticate, Query or
EnumerateSource arriving on conversation 0, which is reserved.
2.7.4 Size limit #
A message MUST NOT exceed 81920 bytes — larger than PGSS Logon's ceiling, because a PSI message wraps one.
2.7.5 Message direction #
The high bit of msg_type marks a message sent by the source. This
follows PGSS Logon's convention that the bit marks the authority for the
matter at hand: on this interface the source is the authority for its
own principals, and the logon authority is the one asking.
2.7.6 Encoding #
PSI shares its codec with PGSS Logon, and PGSS §2.6's encoding rules
apply here unchanged: little-endian multi-byte integers; UTF-8 strings,
length-prefixed and never NUL-terminated; length-framed structures and
array elements, skipped to their declared end; u32 element counts on
arrays, with stated maxima binding on encoder and decoder alike.
2.7.6.1 SIDs on the wire #
SIDs are carried as opaque bytes, in the binary self-relative form PCDS specifies, never as text.
They are opaque to the codec, which has no business knowing what a SID is. They are emphatically not opaque to the authority, which MUST validate every SID it receives before treating it as identity (§2.13). The obligation to check sits in the process that mints tokens, not in the layer that moves bytes.
A SID MUST NOT exceed 68 bytes — the eight-byte prelude plus fifteen sub-authorities, which is the most the encoding's one-byte count admits.
2.7.7 Body extensibility #
The extensibility rules of PGSS §2.6 apply unchanged: fields are appended only, a new field is optional with a safe default, and a new enumeration value is a breaking change requiring a version bump. The one exception is the capability bitmask of §2.8, for the reason given in §2.B.
One PSI-specific application deserves stating. Authenticate (§2.11)
nests a whole LogonStart inside its own length frame rather than
inlining its fields. The obvious encoding — LogonStart's fields, then
PSI's — is wrong: LogonStart belongs to PGSS Logon and grows on PGSS
Logon's schedule, so a field appended there would silently displace the
field after it. Nesting lets the two evolve independently.
The same reasoning applies to every shared body PSI carries, and §2.C lists them.
2.7.8 Initial unpublished layout replacement #
The coordinated SSH credential extension replaced the unpublished version-1 layout without retaining older codecs. Current LogonStart capability, required-method and binding fields, Prompt parameters, and all current Assertion fields are mandatory, including explicit empty/zero values. Omitted mandatory fields fail decoding. The compatibility rules above apply to future extensions of this replacement contract; they do not require accepting the superseded development layout. See PGSS Logon §2.D.
2.8 Registration
Peios / Peios technical reference / PSPU / Principal Source Interface
A connection opens with registration. Nothing else may precede it.
2.8.1 Register #
msg_type = 0x8001. Source to authority, on conversation 0.
| Field | Encoding | Limit |
|---|---|---|
source_name | string | 32 bytes |
domain | length-framed bytes (SID) | 68 bytes |
capabilities | u32 | §2.B |
entry_ttl | u32 | seconds |
max_batch | u32 | 64 |
2.8.1.1 source_name #
What the source calls itself. Bounded at 32 bytes because it becomes the authentication-package name on every session the source authenticates — so a token's provenance answers which source vouched for this? rather than merely the authority minted it.
The name is a claim. It MUST be cross-checked against the identity the authority established for itself (§2.9), and it MUST NOT be used for anything else. A mismatch MUST be refused rather than quietly corrected: a service registering under another's name is worth failing on, not normalising.
2.8.1.2 domain #
The SID namespace this source is authoritative for (§2.10).
2.8.1.3 capabilities #
What the source can do beyond authenticating.
| Bit | Name | Meaning |
|---|---|---|
| 0 | QUERIES | Answers Query (§2.15). |
| 1 | ENUMERATES | Answers EnumerateSource (§2.16). |
| 2 | MEMBERS | Can produce a group's membership. |
| 3 | PUSHES_CHANGES | Sends Changed (§2.17). |
| 4 | CHANGES_CREDENTIALS | Answers ChangeCredential (§2.21). |
| 5 | ENROLLS_CREDENTIALS | Answers EnrollCredential (§2.23). |
An authority MUST NOT send a message a source did not declare it answers, and MUST NOT set a field bit gating a capability the source did not declare. A source declaring nothing authenticates and does nothing else, which is what a source predating these fields is saying by omission — and is the only reading that keeps such a source working.
A source that declares no QUERIES cannot be asked about its principals
outside a logon, so they are unresolvable through PGSS Logon's identity
channel: they can sign in and will appear as bare numbers everywhere
else. That is a coherent configuration and this chapter permits it, but
it is almost never what an administrator intended, and an authority
SHOULD report it where one will see it — as it reports a source
registering with no identifier range.
This is a declaration of capability, not of willingness. A source
declaring QUERIES may still answer Refused to any particular
question (§2.15); a source declaring ENUMERATES may still refuse a
cursor it can no longer honour (§2.16).
2.8.1.4 entry_ttl #
How long, in seconds, the authority may hold an answer from this source before asking again.
Zero means do not cache. A source declaring neither
PUSHES_CHANGES nor a non-zero entry_ttl has said its answers must
not be held at all, and an authority MUST honour that — see §2.17, where
the reasoning for reading silence that way is set out.
entry_ttl and PUSHES_CHANGES are not exclusive, and a source
declaring the second SHOULD declare a non-zero first as well. See §2.17:
the TTL is the backstop against a notification that was never sent.
2.8.1.5 max_batch #
The largest number of keys the source will accept in one Query
(§2.15). Zero means one.
An authority MUST NOT exceed it, and MUST NOT send more than 64 keys whatever the source declared: an encoder MUST NOT declare more, and a decoder MUST read a larger declaration as 64.
A source MUST still validate what it receives. The field is a hint the authority is required to respect, not a guarantee about what will arrive.
2.8.2 Registered #
msg_type = 0x0001. Authority to source, on conversation 0.
| Field | Encoding |
|---|---|
unix_id_base | u32 |
unix_id_count | u32 |
Load-bearing beyond its contents. A source SHOULD report itself ready only once it has received this, so that anything ordered after the source finds a system that can actually authenticate rather than merely a process that exists (§2.3).
2.8.2.1 unix_id_base, unix_id_count #
The POSIX identifier range the authority has assigned this source (§2.20). A base of 0 means no range was assigned, and every principal the source asserts will project as unmapped.
Informational. A source counts within its range and asserts relative identifiers; the authority applies the base. A source MUST NOT apply it — see §2.20, where the reasoning is set out in full.
It is sent so that a source's administration tools can show an operator the identifier a principal will really project to, rather than the relative number the source stores. Without it that arithmetic falls to the operator.
An authority that predates these fields sends neither, and a source MUST read their absence as no range assigned — which is what such an authority means, since it has no ranges to assign.
2.8.3 Rules #
- A connection MUST open with
Registeron conversation0. An authority MUST refuse any connection that opens otherwise. - An authority MUST bound the time it waits for the opening
Register, and close the connection on expiry. A peer that connects and says nothing MUST NOT be able to hold resources indefinitely. - An authority MUST NOT admit two sources under one name at the same time.
- An authority MUST send
Registeredonly after the source is routable, so that a logon racing the acknowledgement cannot find a source that is registered but not yet reachable. - A source MUST NOT send any other message before receiving
Registered. - An authority SHOULD report, where an administrator will see it, that a source registered with no identifier range — its principals will all project as unmapped, and the cause is a configuration omission rather than anything the source did.
2.9 Establishing What a Source Is
Peios / Peios technical reference / PSPU / Principal Source Interface
The authority MUST establish a connecting source's identity for itself, from the kernel, and MUST NOT take it from anything the source sends.
2.9.1 A source proves nothing #
The mechanism worth recommending is that a source proves nothing at all, because the init system already did.
Where the init system places a service SID in each service's token — a SID derived from the service's name, which only the init system can mint — the authority can:
- take its list of permitted source names from its own configuration;
- derive the service SID each of those names implies;
- read the connecting peer's token and ask which of those SIDs it carries.
The resulting identity is assembled entirely from the authority's configuration and the kernel. Nothing is contributed by the process on the other end. There is no shared secret, nothing to provision, nothing to rotate, and nothing to steal — the derivation is a pure function of a name that only the init system can act on.
2.9.2 What is deliberately not checked #
That the peer is SYSTEM. The service SID subsumes it: the user SID could never distinguish one platform service from another, since they all run as the same principal. Requiring SYSTEM as well would needlessly forbid a future source running under a lesser account, which is a direction worth keeping open.
2.9.3 The allowlist #
An authority MUST NOT accept a source it has not been configured to accept.
An empty configuration MUST mean no source may register, not any source may. An allowlist that fails open is not an allowlist. The visible cost is that a system configured with no sources cannot authenticate anyone, which is the correct way for that mistake to present — loudly, at the first logon attempt, rather than silently at the first compromise.
2.10 The Domain Claim
Peios / Peios technical reference / PSPU / Principal Source Interface
A source declares the domain it is authoritative for. Every principal it may assert lives under it (§2.18).
2.10.1 It is a claim #
A source generates or is given its own domain, so nothing about the SID can prove the claim is honest. What gives it weight is entirely what the authority does with it.
An authority MUST apply all of the following.
2.10.1.1 1. A domain MUST be declared #
A source that declares no domain MUST be refused. There would be nothing to confine its assertions to, which is the whole purpose of collecting one.
2.10.1.2 2. The shape MUST be checked #
A domain MUST be a well-formed, locally-issued domain SID: revision 1,
the NT authority (5), the non-unique prefix 21, and three further
sub-authorities — S-1-5-21-A-B-C, four sub-authorities in total.
The shape is the entire check, and it is enough. A source cannot claim
S-1-5-32 (BUILTIN), or the NT authority's well-known range, or
S-1-1-0, because none of them has that shape. There is no list of
forbidden domains to keep in step with the SID catalogue — the
permitted shape excludes every one of them by construction.
2.10.1.3 3. Domains MUST be disjoint #
No two concurrently registered sources may claim the same domain. Two authorities for one namespace means whichever answers first decides who a name belongs to, and the other's principals become impersonable by the first.
2.10.1.4 4. A source MUST NOT change domain #
A source that re-registers MUST declare what it declared before. An authority MUST refuse a change.
This is the check that survives a source restarting, and it is worth its cost: every other check passes for a source that is killed and comes back compromised. It still holds the right service SID, its new domain is still a claimable shape, and with itself deregistered there is nothing left to collide with.
An authority MAY hold this record only for its own lifetime. Persisting it means the authority writing state, which is a larger commitment than the guarantee justifies.
2.10.1.5 5. An administrator MAY pin #
An authority SHOULD allow an administrator to configure the exact domain a named source must declare, and MUST refuse a source declaring anything else when one is configured.
A configured pin that cannot be parsed MUST NOT be treated as absent. Absence means no pin; an unparseable value means an administrator tried to apply the control and got it wrong, and silently downgrading that to "unconstrained" removes the control at the moment it was being applied. An authority MUST fail towards refusing the source.
2.10.2 What remains uncovered #
With no pin configured, and another source not currently registered, a compromised source could declare that source's domain and assert its identities. Disjointness catches it only while both are registered.
This is stated rather than solved. Closing it requires someone to write the pin down, and an authority cannot invent that authority for itself — writing it automatically would mean the process holding the token-minting privilege also holding a configuration write handle, which is a worse trade than the gap it closes.
2.11 Authenticate
Peios / Peios technical reference / PSPU / Principal Source Interface
msg_type = 0x0002. Authority to source. Opens a conversation.
| Field | Encoding | Limit |
|---|---|---|
start | nested LogonStart, length-framed | PGSS §2.7 |
originator | length-framed bytes (SID) | 68 bytes |
2.11.1 start #
The client's LogonStart, nested whole (§2.7). Its fields and their
meanings are PGSS §2.7's, unchanged — including that identifier is an
unverified claim and supported_credential_types binds what may be
prompted for.
2.11.2 originator #
The verified identity of the process that requested this logon, taken by the authority from the client's connected socket and never from a message body.
A source cannot learn this for itself: it is not party to the client's connection, and there is nothing it could ask. The authority relays it because a source may legitimately refuse a logon on the strength of it — an account restricted to console logons needs to know what asked — and that decision needs a trustworthy input.
A source MUST treat originator as established fact and MUST NOT treat
any other field of this message the same way.
2.11.3 Routing #
Before sending Authenticate, an authority MUST decide which single
source answers.
The credential MUST NOT be offered to more than one source. Trying each in turn with the password hands every source the credentials of every other source's users, including on typos — the failure PAM stacking exemplifies (§2.D).
Resolution therefore happens on the identifier, before any credential exists. Asking several sources "do you own this name?" is a resolution step with no secret in it and is permitted; offering them the answer is not.
An authority SHOULD resolve a qualified name to its owning source and MUST NOT fall back to another source when the owning one is unreachable. A name that can fall through lets anyone who can break a network choose which authority answers for a principal.
2.11.4 Conversation limits #
An authority MUST bound the conversations it opens against one source. A
source MUST bound what it will track, and MUST refuse beyond its own
limit with AuthorityUnavailable (§2.13) rather than dropping the
conversation silently.
2.12 The Relayed Interrogation
Peios / Peios technical reference / PSPU / Principal Source Interface
Two messages, both carrying PGSS Logon bodies verbatim. They serve a credential change (§2.21) exactly as they serve an authentication, and everything below applies to both.
2.12.1 CredentialRequest #
msg_type = 0x8002. Source to authority. Body is PGSS §2.8's
CredentialRequest, byte-for-byte.
The source decides what to ask for, in what order, and over how many rounds. The authority relays it to the client.
2.12.2 CredentialResponse #
msg_type = 0x0003. Authority to source. Body is PGSS §2.8's
CredentialResponse, byte-for-byte.
2.12.3 What the authority polices #
The authority relays, but does not relay anything.
An authority MUST refuse to relay a prompt whose credential type is
absent from the client's supported_credential_types. PGSS §2.8 makes
this the authority's obligation towards the client, and it holds however
the authority reached the prompt — a prompt originating in a source is
still the authority's to police.
Relaying it would force the client to hard-fail, and a client that guessed instead might echo a secret to the screen. The authority MUST terminate the logon instead.
An authority MUST also enforce its own round and time limits on the relayed exchange (PGSS §2.3), independently of any the source applies. A source that never terminates a conversation MUST NOT be able to hold a client's logon open indefinitely.
2.12.4 Credential handling #
The obligations of PGSS §2.12 bind both parties on this leg as they do on the client's. Credential material reaching a source has been decoded and re-encoded once more than it would have been without federation, and every buffer it passed through on the way is one the obligation covers.
2.12.5 What the authority does not do #
It does not interpret prompts, rewrite messages, reorder anything, or synthesise a request of its own. A source's prompt reaches the client as the source wrote it, which is the property that makes adding a credential type a change to sources alone.
2.13 Assertion and Refusal
Peios / Peios technical reference / PSPU / Principal Source Interface
Exactly one terminal message ends a source conversation. An
authentication ends in Assertion or Refusal. A credential change
ends in CredentialChanged or Refusal, never in Assertion (§2.21).
2.13.1 Assertion #
msg_type = 0x8003. Source to authority. The only successful
outcome a source can produce.
| Field | Encoding | Limit |
|---|---|---|
user_sid | length-framed bytes (SID) | 68 bytes |
canonical_name | string | 256 bytes |
groups | array of group entries | 128 |
unix_id | u32 | §2.20 |
primary_group | length-framed bytes (SID) | 68 bytes |
profile | length-framed structure (PGSS §2.9) | |
claims | array of claim entries | 64 |
permitted_logon_types | u32 | PGSS §2.16 |
authenticated_credential_type | u8; 0 none, 1 Password, 2 SshPublicKey | PGSS §2.D |
Note what is absent: no session, no token, no privileges, no integrity level. A source has no way to express them (§2.4).
All listed fields are mandatory in the coordinated replacement layout. Empty arrays/strings and zero values represent absent assertions; omitted fields are malformed. The authority checks the authenticated method against the client's capabilities and any required method before granting a token.
2.13.1.1 canonical_name #
The source's own spelling of the principal's name. A client may have
typed JACK; this is what the principal is actually called.
Carrying it is what makes case-insensitive matching safe: the authority records the canonical form rather than whatever was typed, so a session's records do not vary with a caller's shift key.
A source MUST NOT assert a name that PGSS §2.15 forbids — one carrying a
reserved character, a byte outside the printable ASCII range, or a
leading or trailing space. The authority MUST refuse one anyway (§2.22),
because a name from a source reaches a passwd-format record and an
audit line, and by then the damage is the reader's to do.
The obligation is on what a source asserts, not on what it creates. A source that validates a name when an administrator adds it, and not when it reads one back from storage, has enforced nothing against a store it did not itself write.
2.13.1.2 permitted_logon_types #
Which kinds of sign-on this principal may be used for, as the bitmask
PGSS §2.16 defines for the LOGON_TYPES field.
A source states the property and MUST NOT act on it. Deciding what to do about a principal's restrictions is derivation, and derivation is the authority's (§2.4) — a source that refused an authentication on these grounds would be making an access decision it has no standing to make, and would do it differently from the authority that also has to.
Zero means the source states nothing, and a source that does not hold the property MUST send zero rather than inventing a value. In particular it MUST NOT send "everything", which would assert that a principal may be used for a credential-free service logon (PGSS §2.19) on no evidence at all.
Sources that state no restriction send zero explicitly.
2.13.1.3 groups #
Each entry is a separate length-framed structure:
| Field | Encoding | Limit |
|---|---|---|
sid | length-framed bytes (SID) | 68 bytes |
unix_id | u32 | §2.20 |
A SID and a number. No attributes.
A source asserts which groups a principal belongs to. Whether a group entry is enabled, owner-marked, or deny-only is a decision about how to build a token, and building tokens is the authority's (§2.4). A source saying "this principal is an administrator" is identity; a source saying "and mark that group deny-only" would be reaching into derivation.
Both current group fields are mandatory. The per-entry framing preserves the boundary of any future trailing extension.
A unix_id of 0 means the source does not number this group — the
honest answer for a group it does not own. A source naming a well-known
group is stating a membership, not claiming authority over what that
group projects to; see §2.20.
2.13.1.4 unix_id #
The principal's POSIX identifier, relative to the range the authority assigned this source (§2.20). Zero means the source has no number for this principal.
A source MUST NOT apply its own base. It counts within its range and the authority rebases; a source that added the base itself would have it added twice.
2.13.1.5 primary_group #
Which of the principal's groups projects to the POSIX group id, and becomes the default group of objects the token creates. Empty means the source did not say, and the authority chooses.
It need not appear in groups. The authority is required to place it on
the token regardless (§2.22), because a token's primary group must be a
group the token carries — so naming a group here is a membership
claim, and it is subject to membership scope exactly as a listed group
is (§2.19).
That applies to a primary group the source asserted. Where the field is empty and the authority substitutes one of its own, the substituted value is the authority's choice and MUST NOT be tested against the source's membership scope. Testing it would deny every logon from a source that declined to name a primary group, on the strength of a claim that source never made — and an authority's own default is very unlikely to be a sibling of the principal's domain, so the test all but always fails.
2.13.1.6 profile #
PGSS Logon's profile structure (PGSS §2.9), relayed onward to the client unchanged. It is not identity, it decides no access, and the authority does not interpret it.
The one thing the authority does check is the one PGSS §2.9 requires of
it: home and shell, when non-empty, are absolute paths. That
obligation binds the authority towards its client whatever the value's
provenance, so a relayed profile is not exempt from it.
2.13.1.7 claims #
Named, typed attributes fed to conditional ACE evaluation, in the claim attribute format PCDS §5.9 specifies. Each entry is a separate length-framed structure:
| Field | Encoding | Limit |
|---|---|---|
name | string | 255 bytes |
flags | u32 | PCDS §5.9 |
value_type | u32 | PCDS §5.9 |
values | array of length-framed values | 64 |
A claim is the one field here that is a trusted input to access decisions rather than a statement of identity: a conditional ACE can turn a claim into a grant. Which claim names a source may assert is therefore the same kind of question as which groups it may assert, and belongs with membership scope (§2.19).
An authority MUST reject an assertion carrying a claim it cannot carry to a token — an unsupported value type, a name containing an interior NUL, a value exceeding its limit — rather than dropping the claim. The reasoning is rule 3 below: a dropped claim signs the principal in against a policy nobody stated.
2.13.1.8 What the authority MUST do with an assertion #
- Validate every SID with a structural check before treating it as identity. Bytes from another process are bytes until checked, and the check belongs in the process that mints tokens rather than in the codec that moved them (§2.7). This includes SIDs carried inside a claim value.
- Enforce identity scope (§2.18), membership scope (§2.19) —
including over
primary_group— and numeric scope (§2.20). - Fail the logon on a malformed group SID, an unusable claim, an
invalid
canonical_name, or aunix_idoutside the source's range, rather than dropping it. Dropping would sign the principal in with authority the source did not state — a confusing way to be wrong at best, and for a claim, a silent change of the policy that will be applied to them. A source that cannot encode a SID is broken. - Drop a logon SID from the asserted groups, loudly. No source is authoritative for one: the kernel mints them per session, and this session's did not exist when the source answered. A source asserting one is either buggy or reaching for a different session's SID, which would forge membership of somebody else's logon.
- Drop duplicates, first mention winning. A source asserting a group the authority also derives is redundant, not wrong.
Rules 4 and 5 drop rather than refuse because the token still ends up correct, and refusing would punish a principal for a source's defect without making anything safer. Rule 3 refuses because the token would not end up correct.
Rules 4 and 5 run before rule 2. A logon SID is by construction outside the principal's domain, so an authority that applied membership scope first would refuse the logon that rule 4 says to survive by dropping. Duplicates are the same shape of problem. The drops are about what a source should never have sent; the scope tests are about what it is permitted to claim, and only what survives the first is subject to the second.
An authority MUST also place primary_group on the token even when the
source did not list it among groups, since a token's primary group
must be a group the token carries.
2.13.2 Refusal #
msg_type = 0x8004. Source to authority.
| Field | Encoding | Limit |
|---|---|---|
denial | u32 | PGSS §2.B |
reason | string | 512 bytes |
Reuses PGSS Logon's denial vocabulary rather than inventing a parallel one, so that relaying a refusal outward needs no lossy translation.
A source MUST NOT refuse with NoSuchSession (PGSS §2.B). It answers
only a request to end a logon session (PGSS §2.22), which the authority
decides alone and never puts to a source, so in a refusal it can only
be a mistake. An authority that receives it MUST NOT relay it, and
SHOULD end the conversation with Internal instead.
CredentialRejected is the same kind of code: it answers only an
enrolment (PGSS §2.23), so a source MUST NOT send it in any other
conversation, and an authority that receives it elsewhere MUST NOT relay
it (§2.23).
A source MUST NOT distinguish an unknown principal from a bad credential — by code, by reason, or by timing (PGSS §2.10, §2.12). The obligation is the source's here, because the source is where the distinction exists to be leaked.
An authority MAY relay reason to the client and MAY replace it. It
MUST NOT relay a reason that reveals a distinction the source was
required not to make.
2.14 Abandon
Peios / Peios technical reference / PSPU / Principal Source Interface
msg_type = 0x0004. Authority to source. No fields beyond the header.
Tells a source that a conversation will not continue: the client hung up, a limit was reached, or the authority terminated the logon for reasons of its own.
2.14.1 Why it exists #
Without it, a client that disconnects mid-prompt leaves the source holding conversation state forever. A source cannot detect this for itself — it is not party to the client's connection — so the authority has to say.
2.14.2 Rules #
- An authority MUST send
Abandonfor any conversation it opened that will not reach a terminal state, unless the connection itself is being torn down. - A source MUST discard all state for the conversation on receipt, and MUST NOT reply.
Abandonis not a terminal message from the source, and noAssertionorRefusalfollows it. If one arrives anyway, the authority MUST ignore it — the conversation is gone, and a late answer to an abandoned question is at best stale.- A source that receives
Abandonfor a conversation it does not know MUST ignore it. It has already cleaned up, which is the outcome the message wanted.
2.15 Query
Peios / Peios technical reference / PSPU / Principal Source Interface
An authority serving PGSS Logon's identity lookup must be able to ask a source about a principal outside a logon: to render a name for a SID, or a POSIX record for a number.
A query is a conversation like any other. The authority allocates the
identifier, Query opens it, and one terminal message closes it.
2.15.1 Query #
msg_type = 0x0005. Authority to source. Opens a conversation.
| Field | Encoding | Limit |
|---|---|---|
fields | u32 | PGSS §2.B |
keys | array of key entries | 64 |
A key entry is a length-framed structure:
| Field | Encoding | Limit |
|---|---|---|
key_type | u8 | §2.B |
name | string | 256 bytes |
sid | length-framed bytes (SID) | 68 bytes |
relative_id | u32 | |
kind | u8 | PGSS §2.B |
Exactly one of name, sid and relative_id is meaningful, selected
by key_type. An encoder MUST leave the others empty or zero.
2.15.1.1 Keys are never absolute #
| Value | Name |
|---|---|
| 1 | Name |
| 2 | Sid |
| 3 | RelativeId |
There is no key type carrying an absolute POSIX identifier, and this is the load-bearing property of the message.
PGSS Logon's lookup accepts one, because that is what getpwuid hands a
name resolver. The authority resolves it: it locates the range
containing the number, subtracts the base, and asks the owning source by
relative identifier.
A source is therefore never asked an absolute number, exactly as it never asserts one during a logon (§2.20).
The reason is not that an absolute number would let a source escape its range — it could not, because the authority refuses a relative identifier at or past the count before adding anything to it (§2.20). The reason is that the arithmetic must exist in exactly one place. A source asked an absolute number would have to subtract its own base to answer, which is the operation §2.20 forbids it, and an authority that asked would have taught it that its stored numbers and the system's are the same numbers. Every subsequent bug in that source would be an off-by-a-base.
2.15.1.2 Batching #
keys is an array so that an authority may ask several questions in one
exchange. An authority MAY send a single key, and one that always does
is conforming.
The array is here from the outset because adding it later would break every source written against a single-key message. A source with a cheap local store gains little; a source backed by a remote directory gains the difference between one query and a hundred.
A source MUST answer every key it is sent, in order, and MUST NOT reorder, merge or omit results. A source that cannot serve a whole batch MUST refuse the conversation rather than answer part of it.
2.15.2 QueryResult #
msg_type = 0x8005. Source to authority. Terminal.
| Field | Encoding | Limit |
|---|---|---|
results | array of result entries | 64 |
One result per key, in the order the keys were sent.
A result entry is a length-framed structure:
| Field | Encoding | Limit |
|---|---|---|
outcome | u8 | §2.B |
sid | length-framed bytes (SID) | 68 bytes |
canonical_name | string | 256 bytes |
kind | u8 | PGSS §2.B |
present | u32 | PGSS §2.B |
withheld | array of withheld entries | 32 |
values | array of length-framed values | 32 |
Everything from present onward is PGSS §2.16's structure, unchanged —
the same reuse of message bodies as the interrogation phase (§2.5), and
for the same reason: an authority relaying a source's answer outward
should not have to translate it.
Where outcome is not Found, everything after it MUST be empty or
zero.
2.15.2.1 canonical_name, not qualified #
A source returns its own spelling of the name, as it does in an
Assertion (§2.13). It does not qualify it.
Qualification names which source answered, and a source cannot know what it is called in another authority's search order. PGSS Logon requires a qualified name on the way out (PGSS §2.15); producing it is the authority's.
2.15.2.2 Identifiers are relative #
Every identifier in a result — a UNIX_ID field, a reference's
unix_id — is relative, exactly as in an Assertion (§2.20). A
source MUST NOT apply a base, and the authority rebases before the
number leaves it.
2.15.2.3 Outcomes #
A source may send only:
| Value | Name | Meaning |
|---|---|---|
| 1 | Found | The source holds this object. |
| 2 | NotFound | It does not. |
| 4 | Refused | It holds it and will not say so. |
A source MUST NOT send Unavailable: it is answering, so nothing was
unavailable to it. The authority produces that outcome when a source
does not answer (PGSS §2.18), and a source claiming it would let a
working source be recorded as a broken one.
A source MUST NOT send Malformed in a result. A message it cannot
parse is a Refusal for the whole conversation (§2.13).
A Refused result is about the object, not about the caller, and an
authority MUST NOT relay it outward as PGSS Logon's Refused outcome,
which is reserved for a caller that may not make the request (PGSS
§2.18). What a source declining to expose an object means to a client is
the authority's to decide, and it is not "you lack permission".
A source that declared no QUERIES is a third case again. It has not
answered and cannot be asked, so an authority MUST NOT record it as
having answered NotFound: a source that was never consulted is not
evidence that an object does not exist, and PGSS §2.18 forbids reporting
NotFound on the strength of one. It contributes nothing to the search,
and §2.8 says what an authority should do about the configuration that
produced it.
2.15.3 Scope #
An authority MUST apply identity confinement (§2.18), membership scope
(§2.19) and numeric scope (§2.20) to a QueryResult exactly as to an
Assertion, and MUST validate every SID in one structurally before
using it — including the SIDs of references inside a PRIMARY_GROUP,
GROUPS or MEMBERS value, not only the sid of the result itself.
"Exactly as to an Assertion" is meant literally, and it is the
sentence an implementation is most likely to satisfy by halves. An
authority that confines the object a result names, while relaying the
group references beside it unchecked, has left the whole of membership
scope unenforced on this channel — and a source with no permission to
assert a foreign membership can then report one through a name lookup
that it could not report through a logon.
A query is not a weaker channel than a logon. A source that could name a
principal outside its domain here would be able to make ls -l display
another source's principals as its own — and, worse, could then be
believed the next time something compared that name to a SID.
2.16 Enumeration
Peios / Peios technical reference / PSPU / Principal Source Interface
Query asks about objects the authority can already name. Enumeration
asks a source to produce them: to fill a POSIX passwd or group
table, or to page through a group whose membership will not fit in one
answer.
A source is never required to enumerate. A source that declines is fully conforming, and this section exists as much to make declining safe as to make enumerating possible.
2.16.1 EnumerateSource #
msg_type = 0x0006. Authority to source. Opens a conversation.
| Field | Encoding | Limit |
|---|---|---|
kind | u8 | PGSS §2.B |
fields | u32 | PGSS §2.B |
of | length-framed key entry (§2.15) | |
cursor | length-framed bytes | 256 bytes |
kind MUST NOT be Any.
An empty of enumerates every object of kind the source holds. A
non-empty of MUST name a group, and enumerates that group's members.
That group is one the source holds, or a BUILTIN group (S-1-5-32-…)
named by its SID. A BUILTIN group is no source's object, but a source
permitted to assert memberships outside its domain records who is in it
(§2.19), and the authority asks every such source for its members (PGSS
§2.16). Such a source MUST answer with the principals it records in the
group — by a listed membership or as their primary group — and with an
empty Found where it records none.
cursor is empty on the first request, and otherwise carries the next
from the source's immediately preceding reply.
2.16.2 EnumerateResult #
msg_type = 0x8006. Source to authority. Terminal.
| Field | Encoding | Limit |
|---|---|---|
outcome | u8 | §2.B |
entries | array of result entries (§2.15) | 256 |
next | length-framed bytes | 256 bytes |
An empty next ends the enumeration. A non-empty next means there is
more, even where entries is empty.
A source MUST size a page against the smaller of this chapter's message ceiling and PGSS Logon's, because the authority re-encodes what it returns into the latter (§2.A). Neither the 256-entry bound nor the message ceiling here prevents a page nobody can deliver.
A source that will not enumerate replies Refused, with entries and
next empty. An authority MUST record it as a source that did not
contribute, and MUST NOT retry it for the remainder of that enumeration
— across pages as well as within one (PGSS §2.17).
Refused and an empty Found are different answers and MUST NOT be
conflated. Found with entries and next both empty says there
are none: the group exists and has no recorded members, or the source
holds no objects of that kind. Refused says this source is not
answering. A source that returns an empty Found where it means the
second has told the authority a falsehood it cannot detect, and the
authority will go on asking it — the non-retry rule above has nothing to
attach to.
The cases most often got wrong, all of which are Refused and not an
empty Found: a group whose membership the source will not expose, a
key that names an object the source does not hold, a key that names a
principal where a group was required, and a cursor the source can no
longer honour.
2.16.3 Cursors belong to the source #
A cursor is opaque to the authority. The authority MUST NOT construct, parse or modify one; it relays what it was given.
A source MAY encode anything into a cursor, and MAY refuse one it no
longer honours — a store rewritten underneath a half-finished walk is
the ordinary case, not an exceptional one. A refused cursor is
Refused, and the authority MUST NOT restart the enumeration on the
source's behalf.
A source MUST NOT assume a cursor comes back on the same conversation, or on the same connection, and MUST NOT hold per-cursor state it is unwilling to discard.
The authority's own cursor, the one it hands its client, is its to construct — PGSS §2.17 requires it to reject one it did not issue, which it can only do for a cursor it made. A source's cursor travels inside it, not as it.
2.16.4 Why members are here and not only in Query #
MEMBERS is a field of Query (§2.15), so the common case — a small
group, whose members fit alongside the rest of the record — costs one
exchange.
A group whose membership will not fit is reported through PGSS Logon's
TooLarge (PGSS §2.16), and this is where the caller is sent. Paging a
membership through the mechanism that already pages is cheaper than a
third message, and considerably cheaper than the alternative of a
partial member list, which is a wrong answer rather than a smaller one.
2.16.5 Enumeration is not existence #
An authority MUST NOT use enumeration to determine whether a principal exists, and MUST NOT infer from a source declining to enumerate that the source holds nothing.
A directory-backed source able to answer any single question while quite unable to answer all of them is the expected case, not a degraded one.
2.17 Change Notification
Peios / Peios technical reference / PSPU / Principal Source Interface
An authority answering a name lookup for every process on the system will cache. This is the message that lets it.
2.17.1 Changed #
msg_type = 0x8007. Source to authority, on conversation 0.
Unsolicited, and never answered.
| Field | Encoding | Limit |
|---|---|---|
scope | u8 | §2.B |
sid | length-framed bytes (SID) | 68 bytes |
scope | Name | Meaning |
|---|---|---|
| 1 | All | Everything this source holds may have changed. |
| 2 | Object | The object named by sid may have changed. |
sid is meaningful only for Object, and MUST be within the source's
declared domain (§2.18).
Object scope MUST be used for a deletion as well as a change, and
for a creation — an authority may be holding a cached NotFound for
a name that now exists.
2.17.2 Obligations #
A source declaring PUSHES_CHANGES (§2.8) MUST send Changed before,
or at the same time as, the altered answer becomes observable through
Query.
Sending it afterwards leaves a window in which the authority's cache and the source disagree while both believe themselves current — which is indistinguishable, from the authority's side, from the notification never arriving.
A source MAY send All where it could have sent Object.
Over-invalidation costs a query; under-invalidation costs correctness.
An authority MUST accept Changed at any time after Registered,
including while conversations are open on the same connection, and MUST
NOT reply to it.
An authority MUST treat the loss of a source's connection as All for
that source. It does not know what changed while it was not listening.
A source declaring PUSHES_CHANGES SHOULD declare a non-zero
entry_ttl as well (§2.8). The two are not alternatives: the TTL is the
backstop against a notification that was never sent, or was sent and
failed to write. Declaring PUSHES_CHANGES with a TTL of zero means a
single lost notification leaves the authority holding a stale answer
until the connection drops — and a source that tolerated a failed
Changed write without tearing the connection down would have made that
outcome reachable, which is one of the reasons §2.6 makes a failed write
fatal.
2.17.3 Sources that do not push #
A source that does not declare PUSHES_CHANGES is conforming, and many
cannot: a remote directory has no way to tell this machine that an
account was renamed.
Such a source declares entry_ttl instead (§2.8), and an authority MUST
NOT hold its answers beyond it.
A source declaring neither has said it cannot support caching, and an authority MUST NOT cache its answers at all. That is the safe reading of silence, and it is what a source predating this message says by omission.
An authority that does not cache at all satisfies this section trivially, and is conforming. The obligations here bind what an authority may hold, not whether it must hold anything.
2.17.4 What this does not carry #
Changed says that something changed. It does not say what it changed
to.
Carrying the new value would make this a second, unsolicited path by which a source could assert identity — one arriving outside any conversation, with no key to check it against, and no logon in progress to refuse. An authority that believed it would have accepted an identity assertion it never asked for.
The authority discards what it holds and asks again through Query,
where every scope rule in §2.18 to §2.20 applies.
2.18 Identity Confinement
Peios / Peios technical reference / PSPU / Principal Source Interface
A source may assert only principals within its declared domain (§2.10). No configuration lifts this.
An authority MUST refuse an Assertion whose user_sid does not lie
within the domain the source registered for, and MUST terminate the
logon. It MUST apply the same test to a QueryResult (§2.15) and to the
sid of a Changed (§2.17).
2.18.1 Containment #
A principal SID lies within a domain when it is the domain's SID plus exactly one relative identifier.
Exactly one, deliberately. S-1-5-21-A-B-C-1000-1 is not a principal of
S-1-5-21-A-B-C, and admitting it would let a source that owns one
domain mint names in a nested namespace nobody agreed it owned. A domain
SID is likewise not a principal of itself.
2.18.2 Why nothing lifts it #
A source that could assert identities outside its domain could hand out another authority's principals to anyone who satisfied its credential check.
The concrete case: a local source holds no domain credential. If it were unconfined, it could produce a domain administrator's identity for anyone who knew a local password. The domain's own authority would never be consulted and would have no way to know.
Confinement keeps a compromised source at "authority over its own domain" — which is what it already was — rather than "authority over everyone".
This is why identity scope and membership scope (§2.19) are separate settings rather than one. They are different questions, and only one of them has a legitimate exception.
2.19 Membership Scope
Peios / Peios technical reference / PSPU / Principal Source Interface
Group membership is the question with a legitimate exception, and it needs one.
2.19.1 The default #
By default, an authority MUST refuse an Assertion carrying a group
outside the domain of the principal being asserted.
A directory vouches for its own users and its own groups, and nothing
else. Without this rule, a directory-backed source could declare its
users members of BUILTIN\Administrators — making a remote authority
the arbiter of who administers this machine.
The test is relative: are the group and the principal in the same domain? It needs no configuration and no knowledge of which domain belongs to whom, which is why membership scope is enforceable before anything else about scope is settled.
2.19.2 The exception #
An authority SHOULD allow a source to be configured as permitted to assert memberships outside the asserted principal's domain.
A local source needs it. Local group membership of any principal is a
local decision: "CORP\Domain Admins is in BUILTIN\Administrators" is
a record this machine keeps, not something a domain controller asserts
at it. Without the exception, a local source could not express the one
thing it is most authoritative about.
An authority MAY grant this to more than one source. Nothing about it is exclusive.
2.19.3 Memberships only, never identity #
A source holding this permission remains fully confined on identity (§2.18).
The two are separate because the risks are not symmetric. A source asserting a foreign membership is making a claim about what a principal may do on this machine, which is a local matter and is the local source's business. A source asserting a foreign identity is claiming to be the authority for somebody else's principal, which is never anyone's business but that authority's.
2.19.4 The primary group is a membership #
primary_group (§2.13) is subject to this section exactly as a listed
group is, and an authority MUST apply the test to it.
It would otherwise be a way round: the authority is required to place
the primary group on the token whether or not the source listed it, so a
source that named BUILTIN\Administrators there and nowhere else would
obtain a membership the group array denies it.
An authority that adds an unlisted primary_group to the membership set
MUST do so before applying this section, not after. Adding it
afterwards reintroduces exactly the route the rule closes.
This binds a primary group the source asserted. A default the authority substituted for an empty field is not the source's claim and MUST NOT be tested against the source's scope (§2.13); an authority that tested its own default would refuse every logon from a source that simply left the field empty.
2.19.5 Claims are the same question, unanswered #
A claim (§2.13) is a trusted input to conditional ACE evaluation, so asserting one can produce a grant just as asserting a membership can. Which claim names a source may assert is therefore the same shape of control as this section — and it is not yet specified.
The gap is stated rather than papered over. An authority federating to a source it does not fully trust should consider claims as it considers foreign memberships, and a future revision is expected to define the control here.
2.20 Numeric Scope
Peios / Peios technical reference / PSPU / Principal Source Interface
A source's POSIX identifiers are relative. The authority assigns the range and applies the base; a source is told its range but MUST NOT act on it.
Every unix_id in an Assertion — the principal's and each group's —
and every identifier in a QueryResult is an offset within a range the
authority assigned that source. The authority adds the base before the
number reaches a token or a caller.
This is the numeric counterpart of identity confinement (§2.18). That section stops a source naming principals outside its namespace; this one stops it numbering them outside its namespace.
2.20.1 The range #
An authority MUST assign each source a range, as a base and a
count, spanning [base, base + count).
An authority MUST reserve a band below every source's base for identifiers of its own — well-known SIDs, service SIDs, confinement SIDs — none of which come from any directory. The band has to be generous, because the last of those categories has no bound.
2.20.2 Rebasing #
Given a relative identifier r from a source with range
(base, count), the authority computes base + r, and MUST refuse to
produce a number at all when:
ris 0. Zero is not an identifier; it is how a source says it has no number for something. It MUST NOT becomebase.ris at or pastcount. The source has reached outside the range it was given.
A number the authority declines to produce projects as unmapped, which the Peios Kernel TRM §3.10.1 defines.
2.20.3 Refuse, never clamp #
An out-of-range identifier MUST be refused. It MUST NOT be clamped to the top of the range, and it MUST NOT be reduced modulo the count.
Both alternatives look like graceful degradation and are worse than a refusal:
- Clamping puts two principals on one number, so a filesystem cannot tell them apart.
- Wrapping lands inside somebody else's range, so one source's principal projects as another source's.
The range is a boundary, not an offset, and the count is what makes it one.
2.20.4 Two numbers a source can never reach #
Because the base sits above the reserved band and a relative identifier cannot escape the count:
- uid 0. It belongs to the authority's own table and is not reachable by adding a base to anything a source can send.
- Another source's numbers. Whatever a source asserts, arithmetic confines it to its own range.
Neither depends on the source behaving. They hold because of what the source is able to express.
2.20.5 Well-known SIDs are not the source's to number #
An authority MUST use its own identifier for any SID in its reserved
band, and MUST ignore whatever unix_id a source sent alongside it.
A source asserting BUILTIN\Administrators is stating a membership. It
is not claiming authority over what that group projects to, and
honouring a relative identifier there would place a well-known group
inside that source's range — where a second source could number
something else identically.
2.20.6 The authority tells the source its range #
A source is told its base and count at registration (§2.8). This is informational and exists so an administration tool can show an operator the identifier a principal will really project to, rather than the relative number on disk.
A source MUST NOT apply the base to what it asserts. It is told the range so it can explain itself, not so it can do the arithmetic. A source that applied its own base would have it applied twice.
Disclosure costs nothing that matters. What confines a source is not ignorance of the base but the authority's refusal to accept a relative identifier at or past the count — a check the authority performs on every number it rebases, whatever the source knows.
2.20.7 Uniqueness within a source #
SIDs are one namespace; POSIX user and group identifiers are two. A source MUST therefore allocate from a single counter across every kind of object it holds, so that a number issued to a principal is never issued again to a group.
An authority cannot check this — it sees one assertion at a time — so it is stated as an obligation on the source (§2.22) rather than as something enforced.
2.21 Credential Change
Peios / Peios technical reference / PSPU / Principal Source Interface
A PGSS Logon credential change (PGSS §2.20) reaches the source that
holds the principal as a conversation of its own kind. It opens with
ChangeCredential, runs the relayed interrogation of §2.12, and ends in
CredentialChanged or Refusal.
authority source
|------------ ChangeCredential ---------->| conversation N
|<----------- CredentialRequest ----------| the current credential
|------------ CredentialResponse -------->|
|<----------- CredentialRequest ----------| the new one
|------------ CredentialResponse -------->|
|<------ CredentialChanged | Refusal -----|
It is not an Authenticate that ends differently. A change asserts
nobody, so it carries nothing an authority could mint from, and keeping
the two apart by message type keeps the path that mints unreachable from
this one (§2.4).
2.21.1 ChangeCredential #
msg_type = 0x0007. Authority to source. Opens a conversation.
| Field | Encoding | Limit |
|---|---|---|
start | nested CredentialChangeStart, length-framed | PGSS §2.20 |
principal | length-framed bytes (SID) | 68 bytes |
2.21.1.1 start #
The client's CredentialChangeStart, nested whole (§2.7), for the
reason Authenticate nests LogonStart (§2.11): it grows on PGSS
Logon's schedule, and a field appended there must not displace
principal. Its supported_credential_types binds what may be prompted
for, exactly as in a logon.
2.21.1.2 principal #
The principal whose credential changes: the user of the client's token, established by the authority from the client's connected socket and never from a message body.
It names both who asked and who is being changed, because PGSS Logon gives a client no way to name anybody else. A source MUST treat it as established fact, MUST NOT change the credential of any other principal in this conversation, and MUST NOT take a principal from anything the client supplies in it.
2.21.1.3 Routing #
An authority MUST send ChangeCredential only to the source
authoritative for principal's domain (§2.18), and MUST NOT offer the
change to any other source. The rule and its reason are those of §2.11:
a credential offered to the wrong source is a credential handed to it.
Where no registered source is authoritative for principal and a
configured source is absent, the authority MUST answer the client
AuthorityUnavailable rather than refusing the account. The absent
source may be the one that holds it, and "this account has nothing to
change" would turn an outage into a statement about the account.
An authority MUST NOT send ChangeCredential to a source that did not
declare CHANGES_CREDENTIALS (§2.8).
2.21.2 The interrogation #
The rounds are those of §2.12, unchanged: CredentialRequest and
CredentialResponse carrying PGSS Logon's bodies, relayed and policed
by the authority as they are in a logon. The source decides what to ask
for.
A source MUST establish that the principal can present their current
credential before changing it (PGSS §2.20), and MUST refuse with
AuthenticationFailed where they cannot. The token the authority read
principal from is not that proof.
A source that proves the current credential in one round and collects the new one in a later round MUST NOT change the credential if the current credential has itself changed in between. Otherwise an administrator's reset between the two rounds would be overwritten on the strength of the credential it replaced.
2.21.3 CredentialChanged #
msg_type = 0x8008. Source to authority. The only successful
outcome of a change conversation. No fields.
A source MUST NOT send it until the new credential is the one it will
verify at its next Authenticate for this principal — until the change
is durable. The authority relays it to the client as PGSS Logon's
CredentialChanged, and PGSS §2.20 forbids reporting a change that a
subsequent logon would not see.
2.21.4 Refusal #
A change that does not end in CredentialChanged ends in Refusal
(§2.13). A source MUST refuse:
- with
AccountRestricted, a principal it holds no changeable credential for: one that authenticates with no credential, one designated only for service logons, or one that is disabled; - with
AuthenticationFailed, a current credential that does not verify; - with
ConversationLimit, a change it gives up on for want of an acceptable new credential.
2.21.5 Terminals out of place #
An authority MUST treat an Assertion in a change conversation, and a
CredentialChanged in any other conversation, as a source fault. It
MUST end the client's conversation with Internal, MUST NOT mint from
the assertion, and MUST NOT report the change.
Abandon (§2.14) applies unchanged. A source discards everything it
held for the conversation, including what a completed round established.
2.22 Conformance
Peios / Peios technical reference / PSPU / Principal Source Interface
A conforming implementation MUST satisfy every requirement in this chapter. This section collects them by role.
2.22.1 Authority obligations #
An authority federating over PSI MUST satisfy all of the following.
2.22.1.1 Channel #
- Listen; never dial out to a source (§2.3).
- Never rely on the socket's descriptor as the access control, and bound unregistered connections and registration time independently of it (§2.6).
- Bound registered sources, and conversations per source (§2.6).
- Tear down the connection on a framing error or a failed write, rather than attempting resynchronisation (§2.6).
2.22.1.2 Registration #
- Require
Registeron conversation0as the first message (§2.8). - Establish the source's identity from the peer's token, never from
source_name, and refuse a mismatch rather than correcting it (§2.8, §2.9). - Accept only configured sources, treating an empty configuration as no source may register (§2.9).
- Refuse a source that declares no domain (§2.10).
- Refuse a domain that is not a well-formed locally-issued domain SID (§2.10).
- Refuse a domain another registered source claims (§2.10).
- Refuse a source declaring a different domain from the one it declared before (§2.10).
- Refuse a source whose declared domain contradicts a configured pin, and treat an unparseable pin as refusing rather than as absent (§2.10).
- Send
Registeredonly once the source is routable (§2.8). - Report, where an administrator will see it, a source registering
with no identifier range, and a source registering without
QUERIES(§2.8).
2.22.1.3 Conversations #
- Allocate conversation identifiers, never accepting one from a
source, never opening one with an identifier already live, and
reserve
0for registration (§2.7). - Route each logon to exactly one source, resolved before any credential is collected (§2.11).
- Never fall back to another source when the owning source is unreachable (§2.11).
- Relay the verified
originator, taken from the client's socket (§2.11). - Refuse to relay a prompt for a credential type the client did not advertise (§2.12).
- Enforce its own round and time limits on the relayed exchange (§2.12).
- Send
Abandonfor any conversation that will not reach a terminal state (§2.14).
2.22.1.4 Assertions #
- Validate every SID structurally before treating it as identity, including SIDs carried inside claim values (§2.13).
- Drop an asserted logon SID, and drop duplicates first-mention-wins, before applying any scope test (§2.13).
- Enforce identity confinement, with no configuration lifting it, on
an
Assertion, on aQueryResult, and on thesidof aChanged(§2.18). - Enforce membership scope, subject only to a per-source permission
covering memberships alone, and apply it to a source-asserted
primary_groupas well as to listed groups — promoting an unlisted one into the membership set before the test (§2.19, §2.13). - Never apply membership scope to a
primary_groupit substituted itself for an empty field (§2.13, §2.19). - Fail the logon on a malformed group SID, an unusable claim, a
canonical_namePGSS §2.15 forbids, or an out-of-range identifier (§2.13). - Place
primary_groupon the token even when the source did not list it amonggroups(§2.13). - Perform derivation itself, and never accept privileges, integrity, or a token from a source (§2.4).
- Never relay a refusal reason revealing a distinction the source was required not to make (§2.13).
2.22.1.5 Identifiers #
- Apply the source's base to every relative identifier it accepts, and never accept one already rebased (§2.20).
- Refuse a relative identifier of
0or one at or past the source's count, rather than clamping or wrapping it (§2.20). - Reserve a band of identifiers below every source's base for its own, and use its own value for any SID within that band regardless of what the source sent (§2.20).
2.22.1.6 Queries #
- Never send a message a source did not declare it answers, and never set a field bit gating a capability it did not declare (§2.8).
- Never send more keys in one
Querythan the source'smax_batch, nor more than 64 whatever it declared (§2.8). - Resolve an absolute POSIX identifier to a source and a relative identifier itself, and never send an absolute one to a source (§2.15).
- Apply identity confinement, membership scope and numeric scope to a
QueryResultexactly as to anAssertion, and validate every SID in one structurally — including those of references inside a value (§2.15). - Rebase every identifier in a result, under the rules of §2.20 (§2.15).
- Qualify a source's
canonical_nameitself; never require a source to (§2.15). - Consult no further source on a
Refusedresult, treat onlyNotFoundas leave to continue, and never relay a source'sRefusedoutward as PGSS Logon'sRefusedoutcome (§2.15). - Never record a source it may not ask — one that declared no
QUERIES— as having answeredNotFound(§2.15). - Relay cursors opaquely, never construct or modify a source's, and never restart an enumeration on a source's behalf (§2.16).
- Record a source that declined or could not be reached, and not retry it for the remainder of that enumeration, across pages as well as within one (§2.16).
- Never infer from a source declining to enumerate that it holds nothing (§2.16).
2.22.1.7 Caching #
- Not cache a source's answers at all unless it declared
PUSHES_CHANGESor a non-zeroentry_ttl(§2.8, §2.17). - Not hold an answer beyond a declared
entry_ttl(§2.8). - Accept
Changedat any time afterRegistered, and never reply to it (§2.17). - Re-read through
Queryafter an invalidation, and never take a new value fromChanged(§2.17). - Treat the loss of a source's connection as
Allfor that source (§2.17).
An authority that holds nothing satisfies 45 to 49 trivially.
2.22.1.8 Credential change #
An authority that relays PGSS Logon's credential change (PGSS §2.20) over PSI MUST also satisfy the following. Obligations 19 to 21 bind a change conversation as they bind an authentication.
- Take
principalfrom the client's connected socket, never from a message body (§2.21). - Send
ChangeCredentialonly to the source authoritative forprincipal's domain, never offer the change to another source, and never send it to a source that did not declareCHANGES_CREDENTIALS(§2.21, §2.8). - Answer the client
AuthorityUnavailable, rather than refusing the account, where no registered source is authoritative forprincipaland a configured source is absent (§2.21). - End the client's conversation with
Internalon anAssertionin a change conversation or aCredentialChangedin any other, minting nothing and reporting no change (§2.21).
2.22.1.9 Credential enrolment #
An authority that relays PGSS Logon's enrolment (PGSS §2.23) over PSI
MUST also satisfy the following. Obligations 19 to 21 and 50 to 53 bind
an enrolment conversation as they bind a change, reading
EnrollCredential for ChangeCredential.
- Never send
EnrollCredentialto a source that did not declareENROLLS_CREDENTIALS, and answer the clientPermissionDeniedwhere the source holding the principal did not (§2.23, §2.8). - Relay
CredentialRejectedonly from an enrolment conversation, and end any other conversation carrying it withInternal(§2.23, §2.13).
2.22.2 Source obligations #
A principal source MUST satisfy all of the following.
2.22.2.1 Connection #
- Connect to the authority; never listen for it (§2.3).
- Open with
Registeron conversation0, carrying its name and its domain (§2.8). - Send nothing else before receiving
Registered(§2.8). - Report itself ready — to an init system or equivalent — only after
Registered(§2.3). - Declare the same domain on every registration, for the life of the machine's configuration (§2.10).
- Tear down the connection on a framing error or a failed write, on
every path including an unsolicited
Changed(§2.6, §2.17).
2.22.2.2 Conversations #
- Reply on the conversation identifier it was given, and never invent one (§2.7).
- Decline to act on a message on a conversation it does not know, and never treat it as opening one — without replying on it, since the identifier may since have been reused (§2.7).
- Refuse an
Authenticate,ChangeCredential,QueryorEnumerateSourcearriving on conversation0(§2.7). - Bound the conversations it tracks itself, rather than relying on the authority's limit (§2.6).
- Refuse beyond that bound with
AuthorityUnavailable, rather than dropping silently (§2.11). - Discard conversation state on
Abandon, and not reply (§2.14).
2.22.2.3 Answering #
- Send exactly one terminal message per conversation:
AssertionorRefusalfor an authentication,CredentialChangedorRefusalfor a credential change (§2.13, §2.21). - Assert only principals within its declared domain (§2.18).
- Assert group SIDs and identifiers only, never attributes (§2.13).
- Carry the canonical spelling of the principal's name in
canonical_name(§2.13), and never assert a name that PGSS §2.15 forbids — validating what it asserts, not only what it creates. - Never distinguish an unknown principal from a bad credential — by denial code, by reason, or by timing (§2.13).
2.22.2.4 Identifiers #
- Assert relative identifiers only, and never apply its own base (§2.20).
- Allocate from a single counter across every kind of object it holds, so that no number is issued twice (§2.20).
- Send
0for any object it does not number, including every group it does not own (§2.20). - Never issue an identifier at or past the count it was given (§2.20).
2.22.2.5 Queries #
A source declaring no capabilities (§2.8) is exempt from this section entirely.
- Declare only capabilities it implements, and answer every message type it declared (§2.8).
- Answer every key of a
Query, in order, without reordering, merging or omitting — or refuse the conversation whole (§2.15). - Send
Found,NotFoundorRefusedin a result, and neverUnavailableorMalformed(§2.15). - Return its own canonical spelling of a name, unqualified (§2.15).
- Return relative identifiers in a result, exactly as in an
Assertion(§2.15, §2.20). - Answer only for principals within its declared domain, on a query as on a logon (§2.18).
- Answer
Refused, never an emptyFound, wherever it is declining rather than reporting an absence — including a membership it will not expose, a key naming an object it does not hold, and a key of the wrong kind (§2.16). - Refuse a cursor it can no longer honour, rather than restarting or answering from a changed store (§2.16).
- Hold no per-cursor state it is unwilling to discard unasked (§2.16).
- Size a page against the smaller of PSI's message ceiling and PGSS Logon's, since the authority must re-encode it into the latter (§2.16, §2.A).
- Send
Changedbefore the altered answer becomes observable, if it declaredPUSHES_CHANGES(§2.17). - Declare a non-zero
entry_ttlif it does not push changes and can tolerate its answers being held, and SHOULD declare one even if it does (§2.8, §2.17).
2.22.2.6 Credentials #
- Store verifiers that are not usable as credential material — nothing a challenge could be recomputed from (PGSS §2.11).
- Erase credential material, and every buffer it was decoded through, before that memory is released (PGSS §2.12).
- Never write credential material to a log, audit record, or diagnostic (PGSS §2.12).
2.22.2.7 Logon type restrictions #
- State a principal's
permitted_logon_typeswhere it holds the property, and send zero where it does not (§2.13). - Never send "everything" for a principal whose restrictions it does not hold — zero is the honest answer, and "everything" asserts that the principal may be used for a credential-free service logon (§2.13, PGSS §2.19).
- Never refuse an authentication on the strength of the restrictions it states. A source states; the authority decides (§2.4, §2.13).
- Answer
LOGON_TYPESfrom aQueryas stored, including "not stated", and never substitute a default of its own — what silence means is the authority's reading, and two components with their own defaults can disagree (§2.15, PGSS §2.16).
2.22.2.8 Credential change #
A source that declares CHANGES_CREDENTIALS MUST also satisfy the
following.
- Change only the credential of
principal, and never take a principal from anything the client supplied (§2.21). - Establish that the principal can present their current credential
before changing it, and refuse with
AuthenticationFailedwhere they cannot (§2.21). - Never change a credential on the strength of a current credential that has changed since it was proved (§2.21).
- Refuse with
AccountRestricteda principal it holds no changeable credential for (§2.21). - Send
CredentialChangedonly once the new credential is the one its nextAuthenticatefor that principal will verify (§2.21).
2.22.2.9 Credential enrolment #
A source that declares ENROLLS_CREDENTIALS MUST also satisfy the
following.
- Add or remove credentials only of
principal, and never take a principal from anything the client supplied (§2.23). - For both adding and removing, establish that the principal can
present a credential they already have before changing anything,
refuse with
AuthenticationFailedwhere they cannot, and refuse withAccountRestricted, before asking, a principal it can ask for no such credential (§2.23). - Check the material again when applying the change, refuse material it
will not accept with
CredentialRejected, and send that code in no other conversation (§2.23). - Send
CredentialChangedonly once the change is durable, and, where it declaresPUSHES_CHANGES, only afterChanged(§2.23, §2.17).
2.22.3 What a source is not required to do #
A source is not required to store anything, to be local, to be persistent, or to know what a token is. It answers one question: given this identifier and whatever it chose to ask for, who is this?
Nor is it required to trust the authority beyond the connection. A
source that refuses logons on the strength of originator, or declines
to answer for principals it holds but does not wish to expose, is
conforming.
2.23 Credential Enrolment
Peios / Peios technical reference / PSPU / Principal Source Interface
A PGSS Logon enrolment (PGSS §2.23) reaches the source that holds the
principal as a conversation of its own kind. It opens with
EnrollCredential, runs the relayed interrogation of §2.12, and ends in
CredentialChanged or Refusal.
authority source
|------------ EnrollCredential ---------->| conversation N
|<----------- CredentialRequest ----------| the current credential
|------------ CredentialResponse -------->|
|<------ CredentialChanged | Refusal -----|
It is the sibling of the credential change (§2.21), and every rule that
section states binds here unless this section says otherwise: routing to
the source authoritative for the principal, AuthorityUnavailable while
a configured source is absent, the principal as established fact, the
terminals out of place, and Abandon.
2.23.1 EnrollCredential #
msg_type = 0x0008. Authority to source. Opens a conversation.
| Field | Encoding | Limit |
|---|---|---|
start | nested CredentialEnrollStart, length-framed | PGSS §2.23 |
principal | length-framed bytes (SID) | 68 bytes |
start is the client's CredentialEnrollStart, nested whole for the
reason ChangeCredential nests its start (§2.21). principal is the
user of the client's token, established by the authority from the
client's socket, exactly as in ChangeCredential. A source MUST NOT add
a credential to, or remove one from, any other principal in this
conversation.
2.23.2 The capability #
An authority MUST NOT send EnrollCredential to a source that did not
declare ENROLLS_CREDENTIALS (§2.8). Where the source that holds the
principal did not declare it, the authority MUST answer the client
PermissionDenied: for that principal it does not offer enrolment, and
PGSS §2.23 gives that answer to an authority that does not.
ENROLLS_CREDENTIALS and CHANGES_CREDENTIALS are independent. A
source may change passwords and not enrol keys, or the reverse.
2.23.3 The interrogation #
The source MUST establish that the principal can present a credential
they already have, other than the one being enrolled, before it adds or
removes anything, for Add and Remove alike, and MUST refuse with
AuthenticationFailed where they cannot. The token the authority read
principal from is not that proof. A source that cannot ask for such a
credential for this principal — the principal has none, or signs in only
with the kind being enrolled — MUST refuse with AccountRestricted
before asking anything.
The source decides which credential types it enrols and what material it
accepts. It SHOULD check the material before asking for proof, MUST
check it again when it applies the change, and MUST refuse material it
will not accept with CredentialRejected. A source MUST NOT send
CredentialRejected in any conversation but this one, and an authority
receiving it in any other MUST end the client's conversation with
Internal (§2.13).
2.23.4 CredentialChanged #
The successful terminal is CredentialChanged (§2.21), sent only once
the change is durable: an added key is one the source's next
Authenticate will accept where the principal's policy allows keys, and
a removed key is one it will not.
A source that declares PUSHES_CHANGES MUST send Changed (§2.17)
before CredentialChanged, as for any other change to what it holds.
Enrolment does not change which credentials the principal may sign in with; that remains the source's administration.
Appendix 2.A Message Reference
Peios / Peios technical reference / PSPU / Principal Source Interface
2.A.1 Messages #
msg_type | Message | Direction | Conversation | Defined in |
|---|---|---|---|---|
0x8001 | Register | source → authority | 0 | §2.8 |
0x0001 | Registered | authority → source | 0 | §2.8 |
0x0002 | Authenticate | authority → source | 1+ (opens) | §2.11 |
0x8002 | CredentialRequest | source → authority | 1+ | §2.12 |
0x0003 | CredentialResponse | authority → source | 1+ | §2.12 |
0x8003 | Assertion | source → authority | 1+ (terminal) | §2.13 |
0x8004 | Refusal | source → authority | 1+ (terminal) | §2.13 |
0x0004 | Abandon | authority → source | 1+ (terminal) | §2.14 |
0x0005 | Query | authority → source | 1+ (opens) | §2.15 |
0x8005 | QueryResult | source → authority | 1+ (terminal) | §2.15 |
0x0006 | EnumerateSource | authority → source | 1+ (opens) | §2.16 |
0x8006 | EnumerateResult | source → authority | 1+ (terminal) | §2.16 |
0x8007 | Changed | source → authority | 0 | §2.17 |
0x0007 | ChangeCredential | authority → source | 1+ (opens) | §2.21 |
0x8008 | CredentialChanged | source → authority | 1+ (terminal) | §2.21, §2.23 |
0x0008 | EnrollCredential | authority → source | 1+ (opens) | §2.23 |
The high bit marks a message sent by the source, which is the authority for its own principals (§2.7).
2.A.2 Protocol constants #
| Constant | Value | Defined in |
|---|---|---|
| Socket path | the implementation's choice | §2.6 |
| Magic | PPSI (50 50 53 49) | §2.7 |
| Version | 1 | §2.7 |
| Header size | 20 bytes | §2.7 |
| Maximum message size | 81920 bytes | §2.7 |
| Reserved conversation | 0 | §2.7 |
2.A.3 Field limits #
| Field | Maximum | Defined in |
|---|---|---|
source_name | 32 bytes | §2.8 |
domain | 68 bytes | §2.8 |
max_batch | 64 | §2.8 |
originator | 68 bytes | §2.11 |
principal | 68 bytes | §2.21, §2.23 |
user_sid | 68 bytes | §2.13 |
canonical_name | 256 bytes | §2.13 |
groups | 128 entries, each SID 68 bytes | §2.13 |
primary_group | 68 bytes | §2.13 |
claims | 64 entries | §2.13 |
claim name | 255 bytes | §2.13 |
claim values | 64 per claim | §2.13 |
| claim string value | 1024 bytes | §2.13 |
| claim octet value | 1024 bytes | §2.13 |
| claim SID value | 68 bytes | §2.13 |
reason | 512 bytes | §2.13 |
keys | 64 entries | §2.15 |
key name | 256 bytes | §2.15 |
results | 64 entries | §2.15 |
withheld | 32 entries | §2.15 |
values | 32 entries | §2.15 |
entries | 256 entries | §2.16 |
cursor, next | 256 bytes | §2.16 |
68 bytes is the largest a SID can be: an eight-byte prelude plus fifteen sub-authorities (§2.7).
A claim name is bounded at 255 bytes of UTF-8 while PCDS §5.9 bounds it at 255 UTF-16 code units. A string's UTF-16 length never exceeds its UTF-8 byte length, so the byte bound is the stricter of the two and satisfies PCDS without transcoding to find out.
The claim limits are otherwise tighter than PCDS §5.9 permits — it allows 1024 values per claim. These bound the work an authority does decoding a message it has not yet decided to believe, and nothing needs a thousand-valued claim from a principal source.
Fields inside a nested LogonStart, CredentialChangeStart,
CredentialRequest, CredentialResponse or profile keep PGSS Logon's
limits (PGSS §2.A).
2.A.4 The ceiling that actually binds a page #
None of the entry counts above is the constraint on how much a source
may return. An entry that fits a PSI message need not fit the PGSS
Logon message the authority must re-encode it into: this chapter's
ceiling is 81920 bytes and PGSS Logon's is 65536, and a QueryResult or
EnumerateResult entry travels outward inside the smaller one.
A source MUST therefore bound a reply by the smaller of the two ceilings, not by this one, and MUST page rather than fill a PSI message it knows an authority cannot forward. A page that fits here and not there is a page nobody can deliver, and the entry-count bounds do not prevent one — 256 entries of a few hundred bytes each exceeds both.
The margin an implementation leaves for the authority's own framing is its own choice; leaving none is a defect.
Appendix 2.B Enumerations
Peios / Peios technical reference / PSPU / Principal Source Interface
Values PSI defines for itself. Everything else it carries is PGSS Logon's — see PGSS §2.B.
Adding a value to any enumeration here is a breaking change requiring a version bump (§2.7). The two exceptions are the capability bitmask below, and PGSS Logon's field bitmask, which PSI carries unchanged and which may gain bits without one.
2.B.1 Key types #
Carried in a Query key entry's key_type (§2.15) as a u8.
| Value | Name | Key is in |
|---|---|---|
| 0 | none | The key entry is absent |
| 1 | Name | name |
| 2 | Sid | sid |
| 3 | RelativeId | relative_id |
Zero is not a key. It is how an EnumerateSource encodes an empty of
(§2.16), which is the only place it may appear; an encoder MUST NOT send
it in a Query key and a decoder MUST reject one that arrives there.
PGSS Logon's key types (PGSS §2.B) share the first two values and differ
in the third, where it carries an absolute POSIX identifier. The
values are not interchangeable and the tables are deliberately separate:
3 means a rebased number on one side of the authority and a relative
one on the other, which is the whole of §2.20 expressed as a number.
2.B.2 Result outcomes #
Carried in a result entry's outcome (§2.15) and in
EnumerateResult.outcome (§2.16) as a u8.
The values are PGSS Logon's (PGSS §2.B). A source may send only these three:
| Value | Name |
|---|---|
| 1 | Found |
| 2 | NotFound |
| 4 | Refused |
Unavailable (3) and Malformed (5) are the authority's to produce
and MUST NOT be sent by a source — see §2.15. A decoder MUST reject
either arriving from a source, rather than leaving the check to a
caller.
2.B.3 Change scopes #
Carried in Changed.scope (§2.17) as a u8.
| Value | Name |
|---|---|
| 1 | All |
| 2 | Object |
2.B.4 Capabilities #
Carried in Register.capabilities (§2.8) as a u32 bitmask.
| Bit | Name |
|---|---|
| 0 | QUERIES |
| 1 | ENUMERATES |
| 2 | MEMBERS |
| 3 | PUSHES_CHANGES |
| 4 | CHANGES_CREDENTIALS |
| 5 | ENROLLS_CREDENTIALS |
Unlike the enumerations above, a bit MAY be added here without a version bump. A source that does not set a bit has not declared the capability, and an authority MUST NOT send a message the source did not declare it answers (§2.8) — so an authority that predates a bit simply never uses it, and a source that predates one never sets it. Both are the safe reading.
MEMBERS gates a field rather than a message: an authority MUST NOT
set the MEMBERS field bit of a Query (§2.15) against a source that
did not declare it.
2.B.5 Claim value types and flags #
A claim's value_type and flags (§2.13) are PCDS §5.9's, and are not
restated here.
They are nonetheless closed on this interface: a decoder MUST reject a value type or a flag bit it does not recognise, rather than carrying it through to an authority that will put it on a token. Adding one is therefore a breaking change to PSI by the rule above, even though the values themselves belong to PCDS.
Appendix 2.C What Is Shared with PGSS Logon
Peios / Peios technical reference / PSPU / Principal Source Interface
PSI is a superset of PGSS Logon (§2.5). This appendix consolidates exactly what is shared, what is added, and what differs — as a checklist for an implementer building both, and as the list to re-examine whenever either specification changes.
2.C.1 Shared unchanged #
| Element | PGSS | Note |
|---|---|---|
LogonStart body | §2.7 | Nested whole inside Authenticate, never inlined (§2.7) |
CredentialRequest body | §2.8 | Byte-for-byte identical |
CredentialResponse body | §2.8 | Byte-for-byte identical |
profile body | §2.9 | Nested inside Assertion, relayed onward unchanged (§2.13) |
| Denial codes | §2.B | Reused by Refusal (§2.13) |
Lookup result body, present onward | §2.16 | Nested inside a QueryResult entry (§2.15) |
| Field bitmask | §2.B | Carried unchanged by Query (§2.15) |
| Object kinds | §2.B | Carried unchanged by a key entry (§2.15) |
| Lookup outcomes | §2.B | A source may send three of the five (§2.B) |
| Header layout, first 12 bytes | §2.6 | Same fields at the same offsets |
| Byte order, string encoding, length framing | §2.6 | See §2.7 |
| Extensibility rules | §2.6 | Append-only; new enum value is breaking |
| Credential-handling obligations | §2.12 | Bind sources too (§2.22) |
| Name rules | §2.15 | Bind what a source asserts (§2.13) |
An implementation that reimplements any of these rather than sharing one definition has taken on the job of keeping two copies in step. The sharing is the point: a translation layer between two byte-identical formats is a place for them to drift.
2.C.2 Added by PSI #
| Element | Defined in |
|---|---|
conversation header field | §2.7 |
Register / Registered | §2.8 |
Authenticate, wrapping LogonStart plus originator | §2.11 |
Assertion | §2.13 |
Abandon | §2.14 |
| Domain claim and its checks | §2.10 |
| POSIX identifiers, and the ranges that confine them | §2.13, §2.20 |
| Claims carried from a source | §2.13 |
Query / QueryResult, and batching | §2.15 |
EnumerateSource / EnumerateResult, and cursors | §2.16 |
Changed, and the cache contract | §2.17 |
| Source capabilities, TTL and batch limit | §2.8 |
| Relative key types, where PGSS Logon's are absolute | §2.15, §2.B |
| Identity, membership and numeric scope | §2.18 to §2.20 |
2.C.3 Differs #
| PGSS Logon | PSI | |
|---|---|---|
| Magic | PGSL | PPSI |
| Header | 12 bytes | 20 bytes |
| Maximum message | 65536 | 81920 |
| Conversations per connection | one | many |
| Connection lifetime | one logon | the source's lifetime |
High bit of msg_type | authority → client | source → authority |
| Success terminal | AccessGranted + token fd | Assertion — no session, no token |
| Socket path | normative | the implementation's choice |
Key type 3 | absolute POSIX identifier | relative identifier |
The success terminal is what makes minting structurally impossible for a source (§2.4); the socket path is normative in PGSS because it is a conformance bar and not here because PSI is not one (§2.1).
The message ceiling is the row most likely to catch an implementer out, because the larger number is the one that does not bind a reply — see §2.A.
2.C.4 When either specification changes #
A field appended to LogonStart, CredentialRequest,
CredentialResponse or profile in PGSS appears here automatically,
because the bodies are shared. That is the intended behaviour and needs
no change to this chapter.
The profile is the one shared body that travels in the opposite direction to the others: the interrogation bodies pass from the authority outward to the client, while the profile originates at the source and is relayed outward through the authority. It is shared for the same reason regardless — one definition, so the value a source states and the value a client reads cannot drift apart.
A new Denial value, a new CredentialType, or any change to the
header's first twelve bytes is a breaking change to both and requires a
coordinated version bump. An implementer maintaining both MUST NOT bump
one alone.
The one exception is a denial added under PGSS §2.6's rule for codes
that only newer messages draw. NoSuchSession is one: it answers only
requests a source is never party to, a source never sends it (§2.13),
and so no PSI peer of either age meets it.
Appendix 2.D Prior Art
Peios / Peios technical reference / PSPU / Principal Source Interface
2.D.1 What this exists to avoid #
The design is shaped more by rejected approaches than adopted ones.
LSA authentication packages. Windows loads authentication packages as DLLs into the LSA process. A defect in any package is a defect in the most privileged process on the system, and the packages are exactly the components most likely to parse hostile input. PSI puts that boundary at a process, permanently: there is no in-process extension point and no message that could create one.
PAM modules. The same objection, plus stacking — offering a credential to each module in turn until one accepts, which hands every module the credentials of every other module's users. PSI resolves which source answers before any credential is collected (§2.11).
NSS. Name service switch modules answer "who is this?" as a library call in whatever process asked, with no boundary at all. PSI's answer is a message from a process that was separately identified.
2.D.2 What is adopted #
RSI's shape, specified in PSPK. A long-lived connection, sources that dial in and register, multiplexed requests tagged with an identifier, and the authority tearing down a connection it cannot parse. PSI is recognisably the same family, and deliberately so — an implementer who has written a registry source will find little surprising here.
The differences are worth naming, because they follow from what is being federated. A registry source is trusted with the correctness of a subtree and the kernel validates its structure; a principal source is trusted with identity, so the checks it faces are about scope — which principals, which memberships, which numbers — rather than about well-formedness alone. And the kernel assigns a registry source no numeric range, because there is nothing to project.
PGSS Logon's interrogation, wholesale. Rather than inventing a parallel vocabulary for prompts and answers, PSI relays PGSS Logon's messages with identical bodies (§2.5). The gain is not brevity but correctness: there is no translation layer to be lossy, and a source's prompt reaches the client exactly as written.
2.D.3 Design influences #
Sources connect inward. The authority never dials out. See §2.3 — this is the single most consequential shape decision in the chapter.
Assertion, not minting. The success terminal deliberately cannot express a session or a token (§2.4). The separation is structural rather than a rule an implementer must remember.
Domains claimed, not assumed. A source states what it is authoritative for and the authority confines it to that (§2.10, §2.18). The alternative — an authority that trusts whatever a source says about anyone — makes every source as dangerous as the most dangerous one.
Relative numbers. POSIX identifiers are the one thing a source states that has no namespace of its own to be confined by, so the range supplies one (§2.20). Nothing else in the protocol needed inventing; a SID already carries its domain.
3.1 Scope and Roles
Peios / Peios technical reference / PSPU / Observability Interfaces
This chapter specifies the observability interfaces: the three channels by which the programs on a Peios system deposit logs and metrics with an observability service, and by which anything on the system asks that service what it holds.
There are three interfaces and they are specified together because they are one contract from the service's side and because two of the three share their encoding, their validation posture and their loss model:
- the Log Ingestion Interface, on which a producer submits log records (§3.6 to §3.8)
- the Metric Ingestion Interface, on which a producer submits metric samples (§3.9 to §3.13)
- the Query Interface, on which a client asks for stored events, logs and metrics and receives records (§3.14 to §3.28)
Three roles participate.
The collector is the process that accepts ingestion on the two datagram channels, serves the query channel, and holds the data in between. There is one collector. It is the party being asked, on all three interfaces — which is why the obligations in this chapter fall mostly on it, and why the producer and client roles are so thin.
A producer is a process that submits metric samples. Its KACS token is conveyed with every datagram, and the collector verifies that it may publish each metric name (§3.9). Service output reaches the log channel through the service manager instead: the service manager authenticates the service origin by the pipe it owns, then acts as the sole log broker (§3.6).
A client is a process that issues a query and reads the result. A client's identity, unlike a producer's, is established by the collector and determines what it may see (§3.28).
One program is commonly all three at once.
This chapter covers:
- the shape of the three channels and why two are datagram and one is a stream (§3.3)
- the loss model, which is the load-bearing decision of the whole ingestion design (§3.4)
- encoding, timestamps and the timestamp domain (§3.5)
- the log record, its fields, and exactly which malformations cost the record and which are merely ignored (§3.6 to §3.8)
- the metric data model, the three metric types, the sample record, and what makes two samples the same time series (§3.9 to §3.13)
- the query channel, its framing, and the four response messages (§3.14 to §3.17)
- the query language: its shape, its lexis, its operators, its ordering and grouping semantics, and the three modes (§3.18 to §3.25)
- cross-type filtering and streaming (§3.26, §3.27)
- what a client is and is not told about data it may not read (§3.28)
- how these interfaces may be extended (§3.29)
- the obligations binding on each role (§3.30)
This chapter does not cover:
- Event emission. Events reach a collector through KMES, not through any interface here; the consumer side of that is specified in PSPK, and emission is a kernel interface offered to privileged callers.
- Event type vocabulary and payload schemas, which belong to whichever subsystem emits the event.
- How a collector stores, indexes, retains or accelerates anything — its own design. The mainline collector's is described in the eventd TRMP.
- Which principals receive publication rights for individual metric names, and how those rights are administered.
- Administering a collector's contents.
The third of those is the point of the whole document. A collector is handed records and asked questions; how it gets from one to the other is exactly what different collectors exist to do differently.
3.1.1 These interfaces are not a conformance requirement #
A system that offers none of these is still Peios. Observability is not in the definition of the platform, and a system that ships a different collector, or none, conforms exactly as well.
They are specified because they are public. Every service has its output brokered into logs, every collection agent is a metric producer, and every dashboard, alerting tool and command-line viewer is a query client. All three of those are third-party positions, and all three need a contract that stays put.
3.2 Terminology
Peios / Peios technical reference / PSPU / Observability Interfaces
Terms defined in PSPK for the KMES event stream — event, header, payload, stamp, sequence number, origin class — are used here with the same meaning and are not redefined. Terms defined in PCDS — GUID, SID, Security Descriptor, ACL, ACE — likewise.
The following terms are specific to this chapter.
Collector: the process that accepts log and metric ingestion and serves queries. The role, not the program: the mainline collector is eventd, and this chapter never requires that it be.
Producer: a process that submits log records or metric samples.
Client: a process that issues a query.
Log record: one line of output from a program, with the light metadata of §3.7 attached. A log record is text; the collector does not parse it.
Metric sample: one measurement of one quantity at one moment, belonging to a time series (§3.13).
Time series: the sequence of samples sharing a name, a label set, and — for histograms — a set of bucket boundaries. Identity is defined in §3.13.
Boot ID: a GUID identifying one boot of the system. Every record a collector stores carries one, so that records from different boots are never interleaved and per-CPU event sequence numbers, which restart each boot, remain unambiguous. It is assigned outside this interface and is visible to a client only as a queryable field.
Datagram: one message on an ingestion channel, carrying either one record or a batch of them (§3.7, §3.11).
Effective query range: the half-open interval a query examines,
[SINCE, UNTIL), with the bounds resolved as §3.19 defines.
Concrete identifier: the event type, log origin or metric name that a stored record actually carries — as distinct from the pattern a query or a Security Descriptor uses to match one. Access control resolves per concrete identifier (§3.28).
3.3 Three Channels
Peios / Peios technical reference / PSPU / Observability Interfaces
A collector listens on three AF_UNIX sockets. Two carry data inward,
one carries it out.
| Channel | Socket type | Direction | Section |
|---|---|---|---|
| Log ingestion | SOCK_DGRAM | producer to collector | §3.6 |
| Metric ingestion | SOCK_DGRAM | producer to collector | §3.9 |
| Query | SOCK_STREAM | request and response | §3.14 |
The pathnames are configuration and this chapter does not fix them. A collector MUST serve each interface on a distinct socket; it MUST NOT multiplex two of them onto one.
3.3.1 Why ingestion is datagram #
Each submission is an independent message. A datagram either arrives whole or does not arrive, so there is no framing to get wrong, no length prefix to parse, no partial read to reassemble, and no connection state to keep for a producer that submits one line an hour. The record boundary is the datagram boundary.
The property that matters more is that a datagram socket cannot exert backpressure. When the receive queue is full the kernel discards the datagram and the sender proceeds. That is the behaviour §3.4 requires, and choosing a stream socket would make it unachievable: a full stream buffer blocks the writer, which is precisely the outcome this design forbids.
3.3.2 Why the query channel is a stream #
A query result is arbitrarily large, is delivered in several messages, and must not be silently truncated — the opposite requirements. It also needs a caller identity, and a peer token can be obtained from a connected stream socket. Both push the same way.
3.3.3 Separation #
The three channels are separated for the same two reasons.
The first is admission. Each listening socket has its own receive queue. Log volume is orders of magnitude above query volume on any normal system, and a burst of either must not delay the other. Three sockets means three populations of caller that cannot starve one another, whatever load any of them is under.
The second is access control. The service manager alone writes the log channel, while service processes write the metric channel under their own conveyed identities; the set that may read either is different again. Those want different Security Descriptors, and a descriptor is a property of a socket.
The separation is not for isolation. One collector serves all three, so a defect or a hang in any of them reaches the others regardless, and this chapter does not pretend otherwise.
3.3.4 Protecting the channels #
A collector MUST protect each socket with a Security Descriptor.
The log socket descriptor MUST deny FILE_WRITE_DATA to the Service
logon group before allowing SYSTEM. The service manager runs as SYSTEM
without the Service group; every service it launches, including a
SYSTEM service, carries that group. The ordered deny therefore makes
the service manager the log broker without making every SYSTEM service
one (§3.6).
The metric socket descriptor is coarse admission only. Every metric
datagram also carries a KACS sender token and every metric name is
authorized for EVENTD_PUBLISH (§3.9). A collector SHOULD let every
authenticated caller send by default, so that a publish grant to anyone
can be used; an operator MAY narrow it.
The query socket descriptor MUST let every authenticated caller connect. What a caller may read is decided per identifier against the read policy (§3.28), so a socket that admitted fewer callers would only make a policy grant to anyone else impossible to use.
A collector MUST NOT rely on the socket's POSIX mode bits for this. On Peios an access decision is routed through the object's Security Descriptor, not through mode bits, so a mode set on a socket pathname does not restrict anything; and an inode created without a descriptor is denied to every caller, so a collector that binds a socket into a directory carrying no inheritable ACEs produces a socket nothing can reach. A collector MUST establish the descriptor on each socket before it begins accepting on it.
3.4 Loss and Backpressure
Peios / Peios technical reference / PSPU / Observability Interfaces
The single decision that shapes both ingestion interfaces is this: a producer is never slowed down, and never told that a record was lost.
3.4.1 The obligation #
A collector MUST NOT exert backpressure on a producer. A producer MUST NOT stall, block, retry or otherwise change its behaviour because a collector is slow, busy, or absent.
The consequence is accepted openly. When a collector cannot drain an ingestion socket as fast as producers fill it, the kernel discards datagrams. Neither party is notified. A collector MUST NOT report the loss to the producer, because there is no reply message on a datagram channel and adding one would reintroduce the coupling this rule exists to prevent.
3.4.2 Why loss is acceptable here #
A lost log line is an inconvenience. A lost metric sample is a visible gap in a chart. Neither is a failure of the system, and neither is worth the cost of the alternative — which is either blocking the producer or buffering without bound, and the second is only the first with a delay.
Events are the counter-example, and the reason the boundary between events and logs matters. An event may be a security audit record whose absence is itself the finding, so events do not travel on either interface in this chapter: they travel through KMES, where loss is detected, bounded and recorded. A program with data that must not be lost emits an event; a program with output for a human to read writes a log.
3.4.3 What a collector must not do about it #
A collector MUST NOT emit an event, write a log entry, or perform any other work proportional to the volume of malformed or unwanted input it receives.
Ingestion input is untrusted even though its admission is authenticated (§3.3). A collector that reacted to bad input — by logging it, by counting it in a way a client can observe, or by emitting a diagnostic event — would hand an admitted process an amplification primitive: a cheap malformed datagram producing an expensive durable record. Silence is the defence. In-memory diagnostic counters and rate-limited standard error output are permitted because they do not grow with durable input.
The rule binds only on responses to input. A collector MAY record its own internal conditions, and the mainline collector records several, as synthetic events and as health metrics (eventd TRMP §2.6, §5.7).
3.4.4 Ordering and duplication #
A collector MUST NOT assume that datagrams arrive in the order they were sent, and MUST NOT reorder or deduplicate the records inside one. A producer MUST NOT assume that submitting two datagrams in order causes them to be stored in that order; the timestamp field (§3.7, §3.11) is the only ordering a producer controls.
Records are not deduplicated. A producer that submits the same record twice has produced two records.
3.5 Encoding and Time
Peios / Peios technical reference / PSPU / Observability Interfaces
3.5.1 MessagePack #
Every structured value on all three interfaces — log records, metric samples, query requests, query responses — is encoded as MessagePack. Strings are UTF-8.
The choice is inherited rather than made here: KMES event payloads are a single MessagePack value, so a collector already carries a decoder and a query result can carry an event payload outward without re-encoding it.
A decoder MUST accept any valid MessagePack encoding of a value it is given. In particular a producer MAY use any length-prefix width that can represent the value, and a collector MUST NOT require the shortest.
3.5.2 Canonical MessagePack #
Where this chapter requires a canonical encoding, the value MUST be encoded as follows:
- Nil and booleans use the fixed singleton encodings.
- Integers use the shortest encoding that preserves signedness:
non-negative values use positive fixint,
uint8,uint16,uint32oruint64; negative values use negative fixint,int8,int16,int32orint64. - Floats are encoded as
float64. Finite values use their IEEE-754 binary64 representation; the infinities use the normal binary64 encodings; any NaN is encoded as the single quiet NaN bit pattern0x7ff8000000000000. - Strings, binary values, arrays and maps use the shortest length-prefix form capable of representing the length.
- Arrays encode each element recursively, in order.
- Maps encode keys and values recursively, with entries sorted by the canonical encoded key bytes; ties are broken by the canonical encoded value bytes.
Canonical encoding exists so that two values that are equal are also byte-identical, which is what makes them comparable and orderable without decoding. It is required in exactly two places: histogram sample storage, where it makes a sample map a stable value (§3.11), and array comparison in query ordering and grouping (§3.21).
It does not constrain what a producer sends. Ingestion accepts any valid encoding.
3.5.3 Timestamps #
A timestamp is wall-clock time in nanoseconds since the Unix epoch, UTC, as a signed 64-bit value.
The timestamp domain is 0 to 9223372036854775807 inclusive. A
value outside it is invalid wherever it appears: as a producer-supplied
timestamp (§3.8, §3.12), as a query time literal (§3.19), or as a value
in a result record.
The domain has no negative half. A collector MUST reject a negative
timestamp rather than storing a time before 1970, and a query whose time
arithmetic lands below zero — SINCE 100000d ago, for example — MUST
produce an error rather than clamping.
Wall-clock time is not monotonic. A collector MUST store the timestamp it is given or derives without correcting it, and MUST NOT assume that timestamps within one time series increase (§3.13). A clock step backwards produces records that are out of order with respect to their arrival, and every ordering rule in this chapter is defined to remain total and deterministic when that happens.
3.6 The Log Channel
Peios / Peios technical reference / PSPU / Observability Interfaces
A collector MUST expose an AF_UNIX SOCK_DGRAM socket for log
ingestion, protected by a Security Descriptor as §3.3 requires.
3.6.1 The datagram ceiling #
A collector declares a maximum accepted datagram size, the log datagram ceiling. The portable ceiling is 262144 bytes. Every collector MUST accept a whole datagram up to that size, and every portable producer MUST bound its encoded datagrams to that size without discovering collector configuration. A collector MAY accept more, but a producer MUST NOT depend on that when implementing this interface.
A collector MUST receive log datagrams into a buffer of at least its declared ceiling, and MUST discard a datagram the kernel reports as truncated rather than storing the prefix that fitted.
A producer MUST NOT send a log datagram larger than the ceiling. One that does is discarded whole, taking every record in it, and the producer is not told (§3.4).
A sending socket's own buffer can be smaller than the ceiling. A
producer that may send a datagram longer than its socket's default send
capacity SHOULD first raise SO_SNDBUF enough to accommodate it.
On Linux, a Unix datagram longer than the effective SO_SNDBUF
returned by getsockopt minus 32 bytes is refused with EMSGSIZE.
Linux doubles the value requested with setsockopt(SO_SNDBUF) for
bookkeeping; the effective value must therefore be at least the
datagram's length plus 32 bytes. An ordinary unprivileged request is
capped at net.core.wmem_max before that doubling. The initial buffer
comes from net.core.wmem_default; neither sysctl has a universal value.
For example, with both sysctls set to 212992 bytes, the initial buffer
allows datagrams of at most 212960 bytes, below the portable ceiling.
Requesting 212992 bytes gives an effective buffer of 425984 bytes and a
datagram limit of 425952 bytes. A larger datagram needs a higher
wmem_max for an ordinary unprivileged sender. A collector's configured
ceiling does not itself enlarge the sender's buffer.
No capability exchange is needed. The 262144-byte floor is part of the wire contract rather than a tunable assumption shared out of band. Raising a collector's ceiling is a local extension; lowering it below the portable ceiling is non-conforming.
3.6.2 The receive queue is the buffer #
A collector MAY enlarge the socket receive queue, to at most four times the datagram ceiling. It MUST NOT buffer beyond it.
That queue is the only cushion between a producer and the collector's storage. While a collector is committing a batch it is not draining the socket, and datagrams arriving in that window occupy the queue; when the queue fills, they are discarded. This is the designed degradation (§3.4), not a failure to be tuned away — a larger buffer moves the threshold without changing what happens at it, and an unbounded one converts data loss into memory exhaustion.
3.6.3 The service-manager broker #
Every service produces output, including programs that know nothing about the collector, but service processes do not write this socket. The service manager holds each service's standard output and standard error at fork, determines the origin from the pipe it read, and forwards the resulting records (peinit TRM).
The log socket MUST implement the deny-Service, allow-SYSTEM descriptor in §3.3. A collector MUST accept log origins from this broker without a per-record KACS token or AccessCheck. A service can choose the bytes it writes to its pipe but cannot choose another service's origin.
Direct log submission is not part of this interface. A program that wants structured, authenticated observability data emits an event or a metric; it does not bypass the service manager to assert a log origin.
3.7 Log Records
Peios / Peios technical reference / PSPU / Observability Interfaces
A log datagram carries either one record, encoded as a MessagePack map, or several, encoded as a MessagePack array of maps. A collector MUST accept both forms. A producer MAY use either at any time; there is no mode and no negotiation.
3.7.1 Fields #
| Field | Type | Required | Meaning |
|---|---|---|---|
origin | string | yes | Non-empty name of the program that produced the line. |
is_error | bool | yes | True if the line came from standard error, or the producer marked it an error. False otherwise. |
message | string | yes | The log text — one line of output. MAY be empty, which is a blank line. |
timestamp | integer | no | When the line was produced, in the timestamp domain (§3.5). Absent means the collector uses its own clock at receipt. |
job_id | binary, 16 bytes | no | A GUID in PCDS binary layout correlating this line to one execution of one program. |
A collector MUST ignore fields it does not recognise (§3.29).
3.7.2 origin #
origin is what the producer says it is. A collector MUST NOT verify
it, because it has no way to: the channel is a datagram socket and
carries no peer identity (§3.4). Two producers MAY use the same origin,
and one producer MAY use several.
An origin is nonetheless the unit that read access is granted on
(§3.28), and a collector matches it against patterns using dot-delimited
prefix semantics: the pattern svc matches the origin svc and any
origin beginning svc., and matches neither svc_daemon nor svcfoo.
It also matches every origin whose part before a slash is svc, such as
svc/HealthCheck, because matching considers only that part.
A producer therefore SHOULD choose an origin that names it stably and distinguishably, and SHOULD use dots for hierarchy, because an administrator writing an access rule has nothing else to write it against.
An origin MUST match:
origin := component | component "/" producer
producer := component | component "[" [0-9]+ "]"
component := [A-Za-z0-9_][A-Za-z0-9_.-]*
A collector MUST discard a record whose origin does not.
A single component is the identifier grammar of §3.19, widened only to
admit a leading digit, and names a program. The optional second
component names a producer within that program — a hook, a reload
command, a health check, one submitted job — and the bracketed integer
distinguishes several of the same kind. A forwarding service manager
uses it for exactly that: jellyfin/ExecStartPre[0] is a pre-start
hook's output, jellyfin/HealthCheck a health check's, jobs/<guid> a
submitted job's. There is at most one slash, and the index may appear
only after one.
Everything the grammar still excludes, it excludes on purpose. An
origin is not merely a label: it is matched against patterns in which
* is the wildcard, so an origin containing * could not be selected
exactly and could match a rule its producer was never meant to satisfy;
and it is the name an access rule is stored under, so an origin carrying
a backslash, a quoting character or whitespace could land somewhere
other than where the administrator who wrote the rule believes it is.
Constraining the producer is the only point at which either can be
prevented.
The slash is safe there only because it is never written into a stored rule. A collector MUST resolve the access rule for an origin from the part before the slash, so that a service's hooks, reloads, health checks and jobs answer to the rule written against the service — and a producer cannot reach a rule of its own by inventing a second component.
A collector MUST count discarded origins and MUST make that count observable to an operator. An unrecognised origin means a producer's vocabulary and a collector's have drifted apart, and every line that producer sends is being dropped; a silent discard makes the most complete kind of log loss the least visible one.
Quoted forms remain valid syntax everywhere an origin may be written
(§3.24). A single-component origin beginning with a letter or _ never
needs them; every other conforming origin does, because the query
language's identifiers admit neither a slash, a bracket nor a leading
digit (§3.19). A pattern may need them too, and a collector holding
origins stored before this rule applied must still be able to return and
select them.
3.7.3 is_error #
is_error is a boolean and deliberately not a severity level.
A forwarding producer can distinguish standard output from standard error and nothing more; inventing five levels out of two file descriptors would be a guess presented as data. A producer with real severity levels either writes them into the message text, where they are text and are searched as text, or emits events, which have types.
A system service SHOULD write informational messages to standard output
and warnings and errors to standard error. This keeps informational
startup and status messages out of is_error == true queries. Warnings
stay in them: they are diagnostics on standard error, and the collector
does not infer severity from text. Peios's own system daemons follow this
rule. A daemon's best-effort kernel-log mirror, where it has one, is
independent of the stream a line is written to.
3.7.4 timestamp #
A producer SHOULD supply the timestamp it captured when the line was produced, not when it submitted it. A producer that batches (§3.8) and omits the field attributes every line in the batch to the moment the collector happened to read it, which discards the timing information the batch was accumulated over.
3.7.5 job_id #
job_id correlates a line to a single execution rather than to a
program. A forwarding producer sets it so that the output of one run of
a service can be separated from the run before and the run after; a
producer with no such notion omits it.
A collector MUST treat it as an opaque 16-byte value. Nothing in this chapter interprets it, and a producer MAY use it for any correlation of its own, provided the value is a GUID.
3.8 Validating a Log Record
Peios / Peios technical reference / PSPU / Observability Interfaces
Every failure on this channel is silent (§3.4). What differs between failures is how much is lost: the whole datagram, one record, or only one field.
3.8.1 The three scopes of failure #
The datagram is discarded when it cannot be resolved into records at all:
- it is not valid MessagePack
- it decodes to something that is neither a map nor an array of maps
- the kernel reported it truncated (§3.6)
One record is discarded, and the others in the same datagram are still processed, when the record itself is unusable:
- a required field is absent
- a required field has the wrong type —
originan integer, say originis the empty string- the map contains a duplicate top-level key
One field is ignored, and the record is still stored, when an optional field is unusable:
timestampis not an integer, is negative, or is outside the timestamp domain (§3.5)job_idis not binary, or is binary of a length other than 16
A collector MUST implement all three scopes as stated. In particular it MUST NOT discard a record because an optional field was malformed: a producer with a broken clock or a mangled correlation key still has a log line worth keeping, and the field it got wrong is the field of least value in the record.
3.8.2 Duplicate keys #
A record map carrying the same top-level key twice MUST be discarded.
A collector MUST NOT resolve the duplicate by taking the first or the
last. MessagePack decoders differ on which they keep, and the fields
here are entirely producer-controlled, so a rule that depended on
decoder behaviour would let a producer choose which of two origin
values a given collector saw. Discarding is the only answer that is the
same everywhere.
3.8.3 Batches #
A batch is validated per record. A malformed record in a batch MUST NOT cost the valid records beside it.
A producer SHOULD batch under sustained load. Batching amortises the syscall over many records, and the ceiling (§3.6) is per datagram, so a batch is also the only way to use the channel's capacity efficiently.
The encoded datagram, batched or not, MUST NOT exceed the ceiling. A producer that batches without bounding the encoded size will eventually build a datagram that is discarded whole — which is the one case where batching loses more than sending singly would have.
3.8.4 What a collector adds #
A collector supplies the boot ID (§3.2) and, when the record omitted
timestamp, its own clock reading at receipt. It MUST NOT alter any
other field, and MUST store message byte-for-byte as given.
A collector MUST NOT parse message. If the text happens to be JSON, or
logfmt, or anything else structured, that is the producer's business:
this interface carries lines, and a producer with structured data to
record emits an event instead (§3.4).
3.9 The Metric Channel
Peios / Peios technical reference / PSPU / Observability Interfaces
A collector MUST expose an AF_UNIX SOCK_DGRAM socket for metric
ingestion, protected by a Security Descriptor as §3.3 requires,
separate from the log socket.
Before its first send, a producer MUST enable KACS_SO_PASS_TOKEN on
its persistent sending socket. KACS then attaches the producer's
effective token to every datagram as KACS_SCM_TOKEN. A collector MUST
receive with ancillary space for that token and MUST discard the whole
datagram when the token is absent or the data or control message was
truncated.
For every valid record, the collector resolves the metric name's
publication policy and runs KACS AccessCheck for EVENTD_PUBLISH
against the conveyed token. A denied record is discarded silently.
Records with other authorized names in the same datagram remain valid.
An implementation MAY cache a verdict by stable token identity, token
modification identity, metric name and policy generation.
The channel works exactly as the log channel does, for the reasons given
there: the 262144-byte portable datagram ceiling, truncated datagrams
discarded whole, a receive queue of at most four times the collector's
declared ceiling, no backpressure and no notification (§3.6). A
collector MUST accept at least the portable ceiling and a portable
producer MUST bound its encoded datagrams to it. A producer that may
send a datagram longer than its socket's default send capacity SHOULD
first raise SO_SNDBUF, as on the log channel (§3.6).
3.9.1 A sink, not a collector of its own #
The collector is pushed to. It MUST NOT scrape an endpoint, read a kernel interface, or poll anything to obtain metrics; every sample it holds arrived on this socket because a producer sent it.
What gathers the measurements is a separate concern and a separate program. A collection agent that reads system counters and submits them is an ordinary producer here, with no privileged position and no interface of its own.
3.9.2 Batching #
Batching matters more here than it does for logs. It amortizes both the datagram and conveyed-token costs. A collection sweep produces many samples at once — every CPU core, every disk, every interface — and they share a moment, so a producer SHOULD submit a sweep as one batched datagram rather than as one datagram per sample.
The rules are the log rules: one map or an array of maps, per-record validation, and the encoded datagram bounded by the ceiling (§3.8, §3.12).
3.10 The Metric Data Model
Peios / Peios technical reference / PSPU / Observability Interfaces
A metric is a quantity that varies and is worth watching over time: a utilisation, a queue depth, a running total, a distribution of latencies. Metrics are dense where events and logs are sparse — many measurements of the same thing rather than a record of a thing that happened — and the model reflects that.
A sample is one measurement, of one time series, at one moment. It carries:
- a name, identifying what is measured
- a label set, identifying which instance of it
- a type, fixing how the value is to be read
- a timestamp
- a value, whose shape depends on the type
Name and labels together identify the series (§3.13). The type is a property of the series, not of the sample.
3.10.1 Names #
A name MUST match the identifier grammar of §3.19:
[A-Za-z_][A-Za-z0-9_.-]*
A collector MUST discard a record whose name does not, for the same two
reasons an origin is constrained (§3.7): names are matched against
patterns in which * is the wildcard, and names are what read access is
granted on.
Beyond that, naming is convention and a collector MUST NOT enforce any. The conventions in use are a dot-separated hierarchy from general to specific, the unit as the last component, and a cumulative name for a cumulative quantity:
system.cpu.usage
disk.read.bytes
request.duration.seconds
http.requests.total
3.10.2 Labels #
Labels are the dimensions of a measurement: which core, which device,
which method. cpu.usage with core="0" and cpu.usage with
core="1" are two series, not two samples of one.
Label keys and values MUST be non-empty UTF-8 strings. A key MUST match
the identifier grammar above; a value MUST NOT contain = (0x3D) or ,
(0x2C), which are reserved as delimiters in the collector's canonical
representation of a label set (§3.13). A key MUST NOT be repeated within
one sample, and MUST NOT be any of the six fixed field names a metric
result carries — timestamp, boot_id, name, type, value,
overflow —
because labels and fixed fields share one flat namespace in a result
record (§3.22) and a collision would make the record ambiguous.
A record violating any of these is discarded (§3.12).
Label cardinality is the producer's responsibility. Each distinct combination of label values is a distinct series, so labels whose values are unbounded — request identifiers, user-supplied strings, timestamps — produce series without limit, and a collector is required neither to cap them nor to degrade gracefully when they arrive. A producer SHOULD use labels whose value sets are small and known. A dimension that is not bounded belongs in an event payload, where it costs one field, not in a label, where it costs a series.
A collector may bound its total metric storage through retention without limiting series creation. Peios eventd does this by default: it continues accepting new series but removes the oldest samples when the metric store's logical live size exceeds its configured ceiling. That protects the volume; it does not make high-cardinality labels safe, because an active set larger than the series cache can still thrash ingestion.
3.10.3 Types #
The type is fixed when the series is first seen and is immutable. A sample that resolves to an existing series but declares a different type is discarded (§3.12), permanently and without a response to the producer. A producer that changes the type of a metric it already emits has stopped emitting it. The mainline collector counts these conflicts, retains the latest metric name and expected and received types in memory, and may report them through its bounded operator diagnostics (§3.12).
3.10.3.1 Counter #
A value that only increases, and resets to zero when the producer restarts. Used for cumulative quantities: requests served, bytes transmitted, errors encountered.
A counter value MUST be a finite, non-negative binary64 value.
The raw value is rarely what a reader wants; the rate of change is (§3.25). A decrease is read as a restart rather than as a negative change, which is why the type must be declared: the same number sequence means something different for a gauge.
3.10.3.2 Gauge #
A value that may move in either direction. Used for current state: a utilisation, an amount in use, a depth, a temperature.
A gauge value MUST be a finite binary64 value, and MAY be negative.
3.10.3.3 Histogram #
A distribution of observations across buckets the producer chose. Used where the shape matters more than the mean — latencies above all.
A histogram value carries:
- boundaries: a non-empty array of bucket upper bounds, strictly increasing in the order given
- counts: one cumulative count per boundary, each being the number of observations less than or equal to that boundary; non-decreasing, and each no greater than the total
- total_count: the number of observations
- sum: the finite sum of the observations
Boundaries are part of the series identity (§3.13). A collector MUST NOT sort or reinterpret them; a producer that changes them has started a new series, and SHOULD therefore keep them fixed for the life of a metric.
The final count MAY be less than the total: observations above the highest boundary are the difference between them, and are not otherwise represented. A total of zero is a valid empty sample, in which case every count and the sum MUST be zero.
3.10.4 Values are floating point #
Numeric input MAY be a MessagePack integer or a MessagePack float; both are converted to binary64 with round-to-nearest, ties-to-even. Every value a collector stores and every numeric value a query returns is a finite binary64. The null value in an explicit percentile-overflow result is not a numeric metric value (§3.25).
Non-finite values are refused rather than stored: a record whose value converts to NaN or to either infinity is discarded (§3.12). There is no representation for a missing measurement — a producer with nothing to report sends nothing, and the gap is the answer (§3.13).
3.11 Metric Records
Peios / Peios technical reference / PSPU / Observability Interfaces
A metric datagram carries one record, encoded as a MessagePack map, or several, encoded as an array of maps. A collector MUST accept both (§3.9). Each map is one sample of one series.
3.11.1 Fields #
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | The metric name (§3.10). |
labels | map | no | Key-value string pairs. Absent means the series has no labels, which is not the same as a series whose labels are empty — it is the same series. |
type | string | yes | Exactly "counter", "gauge" or "histogram", lowercase. |
timestamp | integer | no | When the measurement was taken, in the timestamp domain (§3.5). Absent means the collector uses its own clock at receipt. |
value | varies | yes | The measurement. A number for counter and gauge; a map for histogram. |
A collector MUST ignore fields it does not recognise (§3.29).
3.11.2 The histogram value #
For a histogram, value is a map:
| Field | Type | Meaning |
|---|---|---|
boundaries | array of number | Non-empty, finite bucket upper bounds, strictly increasing after conversion to binary64, in the order given. |
counts | array of integer | Cumulative count per boundary. Same length as boundaries. Non-decreasing, each no greater than total_count. |
total_count | integer | Number of observations. |
sum | number | Finite sum of the observations. |
Counts and total_count MUST be MessagePack unsigned integers, or
non-negative signed integers. boundaries and sum MAY be integers or
floats and are converted as §3.10 requires.
3.11.3 type is per record, not per series #
Every record declares its type, including the second and every subsequent sample of a series that already exists.
This is redundant on the wire and deliberately so. A producer holds no state about what a collector already knows, has no way to ask, and MUST NOT be required to establish a series before sampling it: the first sample of a series and the millionth are the same message. The redundancy is what makes a producer stateless, and the cost is one short string per sample.
The collector uses the declaration only on first sight. Afterwards it is a consistency check, and a record that fails it is discarded (§3.10).
3.11.4 Timestamps need not increase #
A collector MUST store a valid sample whose timestamp is older than samples it already holds for that series.
Producers batch, clocks step, and a collection sweep may be submitted
out of order or retried. A collector that refused late samples would
turn any of those into silent data loss, so it accepts them and defines
every ordering it performs over timestamp rather than over arrival
(§3.21, §3.25). Two samples of one series MAY share a timestamp; the
collector orders them deterministically and a client MUST NOT depend on
which comes first.
3.12 Validating a Metric Record
Peios / Peios technical reference / PSPU / Observability Interfaces
Failures on this channel are silent, exactly as on the log channel
(§3.4, §3.8). The scopes are the same, with one difference that matters:
a metric record has no ignorable field. Every field a metric record
carries participates either in the series identity or in the
measurement, so there is nothing whose loss leaves a usable record
behind. A malformed timestamp costs the log line nothing and costs the
sample everything.
3.12.1 The datagram is discarded #
- it is not valid MessagePack
- it decodes to something that is neither a map nor an array of maps
- no KACS sender token arrived
- the kernel reported its data or ancillary data truncated (§3.9)
- the collector cannot query the sender token or resolve publication policy
3.12.2 The record is discarded #
A collector MUST discard a record, leaving the rest of its batch untouched, for any of the following.
Structure
- a required field is absent, or has the wrong type
- the map contains a duplicate top-level key
typeis not exactly"counter","gauge"or"histogram"
Name and labels (§3.10)
nameis empty or does not match the identifier grammar- a label key or value is not a string, or is empty
- a label key does not match the identifier grammar
- a label value contains
=or, - a label key is repeated within the record
- a label key is one of
timestamp,boot_id,name,type,value,overflow
Timestamp
timestampis present and is not an integer, is negative, or is outside the timestamp domain (§3.5)
Counter and gauge values
- the value is not a number
- it converts to a non-finite binary64
- it is negative and the type is counter
Histogram values
- the value is not a map, or its map has a duplicate key
- a field is absent or has the wrong type
boundariesis empty- a boundary or
sumconverts to a non-finite binary64 countsandboundariesdiffer in length- the converted boundaries are not strictly increasing
- a count is negative, the counts are not non-decreasing, or a count
exceeds
total_count total_countis zero and any count orsumis non-zero
Series consistency
- the record resolves to an existing series whose type differs (§3.10)
Publication policy
- the conveyed KACS token is denied
EVENTD_PUBLISHforname(§3.9)
3.12.3 Why the timestamp rule differs from logs #
On the log channel a malformed timestamp is ignored and the record kept; here it discards the record.
The asymmetry is not an inconsistency. A log line with the wrong time is
still the line, and reading it is still worth doing. A sample is a
(time, value) pair and nothing else: attaching the collector's receipt
time to a measurement taken at an unknown moment does not recover the
sample, it fabricates one, and it fabricates one that will be charted
next to real ones. Discarding leaves a gap, which is honest (§3.4).
Absence is different from malformation. A record that simply omits
timestamp is asserting "now", and the collector's clock is the right
answer to that.
3.12.4 Silence, again #
A collector MUST NOT emit an event, write a durable log, or increment anything a query client can observe in response to any failure in this article — with no exception for the series-consistency failure, which is the one that most looks like it deserves one. Local in-memory diagnostic counters and rate-limited standard-error output are permitted by §3.4.
A producer that changes a metric's type is misconfigured, and the
misconfiguration is permanent: every sample is discarded for as long as
the series exists, and the producer receives no response. Peios eventd
increments an in-memory total, retains the latest conflicting metric name
and expected and received types, emits at most one coalesced
standard-error warning per minute, and includes the total and latest
conflict in its SIGQUIT diagnostic dump. None of those signals is part
of the query interface or grows in proportion to rejected input.
3.13 Series Identity
Peios / Peios technical reference / PSPU / Observability Interfaces
Two samples belong to the same time series when they agree on:
- the name, compared exactly, byte for byte; and
- the label set, compared as an unordered set of key-value pairs, each compared exactly; and
- for histograms, the bucket boundaries, compared as an ordered sequence of binary64 values.
Nothing else participates. Type does not: a record whose type disagrees resolves to the series and is then discarded for disagreeing (§3.10). Boot ID does not: a series continues across a reboot, and a client that wants one boot's worth filters for it (§3.25). Time does not.
3.13.1 Order does not distinguish a label set #
{core: "0", host: "a"} and {host: "a", core: "0"} are the same
series. A collector MUST compare label sets as sets.
To do so it needs a canonical form, and the form in use is the reason
label values may not contain = or , (§3.10): pairs are sorted by key
in unsigned UTF-8 byte order, each written key=value, and joined with
commas. Because neither delimiter can occur inside a key or a value, no
escaping is needed and no two distinct label sets can produce the same
string.
The byte form itself is the collector's business and this chapter does not require it. What it requires is the property: a label set has exactly one identity, independent of the order the producer wrote it in. The delimiter reservation is stated normatively because it binds the producer, and a producer cannot see the encoding that motivates it.
3.13.2 Absent labels and empty labels #
A record with no labels field, a record with an empty labels map,
and a record whose labels were all discarded are the same series: the
one with no labels. There is no distinction between "unlabelled" and
"labelled with nothing".
3.13.3 Boundaries are identity, not metadata #
Two histogram samples with different boundaries are different series even when name and labels agree.
This is the consequence that surprises producers, and it is unavoidable: cumulative counts against one set of bucket edges cannot be compared with counts against another, so calling them one series would mean computing percentiles across incommensurable distributions. A producer that re-tunes its buckets each collection cycle creates a series each cycle, each holding a single sample and each surviving until retention removes it.
A collector MUST NOT defend against this. It is a producer defect, it is indistinguishable at the interface from legitimately introducing a new metric, and every defence available — capping series, merging near-equal boundary sets, rejecting a second boundary set for a name — would break a correct producer to inconvenience an incorrect one.
3.13.4 Series are created, never announced #
A series comes into existence when its first sample arrives. There is no registration message, no schema, and no way to declare a series in advance or to retire one.
A collector MUST NOT require a series to be known before a sample of it is accepted, and MUST NOT limit how many series exist. A series with no remaining samples ceases to exist when retention removes the last of them; nothing else removes one.
3.13.5 Gaps are preserved #
A collector MUST NOT interpolate, backfill, or synthesise a sample that a producer did not send.
A missing sample is a real fact about the system — the producer was down, the datagram was dropped, the sweep was late — and it is a fact the metric is often being watched for. A series with a hole in it is returned with a hole in it, and a client that wants a value across the hole computes one itself.
3.14 The Query Channel
Peios / Peios technical reference / PSPU / Observability Interfaces
A collector MUST expose an AF_UNIX SOCK_STREAM socket for queries,
protected by a Security Descriptor as §3.3 requires.
One socket serves all three data types. The mode a query runs in — events, logs or metrics — is determined by parsing the query string (§3.18), never by the transport, so a client needs no connection setup, no mode selection, and no separate endpoint per data type.
3.14.1 One query per connection #
A connection carries exactly one query. A client that wants two concurrent queries opens two connections.
A collector MUST close the connection after the terminal message of a non-streaming query (§3.16), and MUST treat a client disconnect as cancellation of a streaming one.
3.14.2 Identity #
A collector MUST establish the client's identity from the connected socket, before executing anything, by obtaining the peer's token. This is possible here and not on the ingestion channels, and it is the whole reason the query channel is a stream (§3.3).
The token is captured once, at connection time, and is a snapshot. A client whose privileges change while a query runs — and in particular while a streaming query runs, which may be indefinitely — is evaluated throughout against the token it connected with.
If a collector cannot obtain the peer token, it MUST refuse the query. It MUST NOT execute a query for an unidentified caller, and MUST NOT fall back to any other means of identifying one.
3.14.3 No credentials cross this channel #
There is no message with which a client offers a credential and none with which a collector asks for one. Identity is established from the connection and from nothing else.
3.14.4 Concurrency #
A collector MUST bound the number of queries it will execute at once, and MUST reject a query beyond the bound with an error rather than queueing it behind the others.
The streaming bound is the lower of the two and is enforced separately, because a streaming query holds its resources for as long as its client stays connected while an ordinary one holds them for at most a timeout (§3.16).
Both bounds are global. Because every authenticated caller may connect (§3.3), a collector MUST also bound the queries one caller may have running at once, so that one caller cannot occupy every slot. The caller is the user SID of the token captured at connect. A collector MAY leave SYSTEM's queries uncounted. A query beyond the caller's bound is rejected with an error, like one beyond the global bounds.
Neither bound may affect ingestion: queries and ingestion are separate channels precisely so that exhausting one cannot exhaust the other (§3.3).
3.15 Query Framing
Peios / Peios technical reference / PSPU / Observability Interfaces
Every message in either direction is a length-prefixed MessagePack value:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | length | Length of the payload in bytes |
| 4 | length | payload | The request or response body |
length is little-endian, as PSPU §1.2 requires of every integer field
in this document. It counts the payload only; the four bytes of the
prefix are not included.
There is no magic value and no version field. The channel is a Unix socket at a configured path, so there is no possibility of reaching the wrong service by accident in the way a shared header guards against (PSPU §2.7), and versioning is handled as §3.29 describes.
3.15.1 The two size controls #
A collector declares two independent sizes. The query request ceiling is a hard inbound limit. The query response target is a soft outbound batching target. They are separate because an untrusted client must not be able to force an arbitrarily large allocation merely by declaring a request length, while a record the collector has already accepted must remain possible to return.
Inbound. A collector MUST refuse a request whose length exceeds
the request ceiling. It MUST do so without reading the payload — the
point of checking the prefix is to avoid allocating for a request that
a malicious or broken client has declared too large — and it MUST send
an error response (§3.16) before closing the connection. A collector
MUST NOT close on an oversized request silently: a bare close is
indistinguishable from a crash, and leaves a client unable to tell that
shortening its query is the remedy.
Outbound. A collector SHOULD chunk result records so an ordinary response does not exceed the response target (§3.16). The target is not a ceiling: when one complete record would exceed it, the collector MUST send that record alone in one response rather than split, truncate, skip, or reject it.
3.15.2 Every stored record must remain returnable #
A collector MUST NOT store a record whose encoded result representation
cannot fit in the protocol's u32 payload length. This structural
framing bound is the only outbound ceiling.
A single result record is never split across messages (§3.16). A collector MUST therefore ensure that its accepted record encodings have a bounded result expansion that fits one frame. It MAY establish that statically from the protocol's record limits; it need not flatten or encode each record on the ingestion path merely to check response size. An implementation MAY encode a large response incrementally after writing its known length; it need not allocate another buffer as large as the record.
The ingestion ceilings (§3.6, §3.9) remain independent configuration.
Raising one does not require raising the response target: it merely
makes a single-record response above that target more likely. The
structural u32 requirement is a property of the wire encodings, not a
configurable coupling between those values.
3.15.3 Requests #
A request is a MessagePack map:
| Field | Type | Required | Meaning |
|---|---|---|---|
query | string | yes | The query string (§3.18). |
A collector MUST send an error response and close the connection if the
payload is not valid MessagePack, is not a map, omits query, gives
query a non-string value, or contains a duplicate top-level key. It
MUST ignore unrecognised fields that are not duplicates (§3.29).
Unlike the ingestion channels, nothing here is silent. A query client is identified (§3.14), is one of a bounded number, and is asking a question, so telling it what went wrong is neither an amplification vector nor an information leak — with the one exception §3.28 sets out.
3.16 Responses
Peios / Peios technical reference / PSPU / Observability Interfaces
A response is a MessagePack map whose status field names its kind.
There are four.
status | Carries | Meaning |
|---|---|---|
"ok" | records | A chunk of result records. |
"end" | — | The non-streaming query is complete. |
"watch" | — | The streaming query's initial result set is complete. |
"error" | error | The query failed. |
3.16.1 Result messages #
An "ok" message carries records, an array of flat maps (§3.22).
Records are chunked at record boundaries: a successful query sends one
or more "ok" messages, ordinarily at or below the response target
(§3.15). A collector MUST NOT split one record across two messages. If
one complete record exceeds the target, that record occupies an
otherwise empty "ok" message by itself. It MUST NOT be truncated,
partially sent, skipped, or turned into a query error merely because it
exceeds the target.
Each record is self-describing and records in one response MAY carry different sets of keys — event payload fields vary by event type, metric labels vary by series. A client MUST NOT assume a uniform schema across a result set, and MUST NOT infer that a key absent from one record is absent from the data.
A successful query with no matching records sends exactly one "ok"
message with an empty records array, then its terminal message. A
collector MUST NOT omit it: "no records" and "the query has not yet
produced records" are different states and a client must be able to tell
them apart.
3.16.2 The two terminal messages #
"end" terminates a non-streaming query. "watch" marks the point in a
streaming query where the stored result set ends and live delivery
begins (§3.27). A query sends exactly one of them, never both.
Until one has arrived, the query has not succeeded. A collector that
fails partway through MUST send "error", and a client that receives
"error" before either terminal message MUST discard every "ok"
message it received for that query. Partial results are not results:
they are an arbitrary prefix of an ordering that was never completed,
and a client that kept them would silently under-report.
An "error" after "watch" is different. It terminates the stream,
and the records already delivered remain valid — they were complete when
they were sent, and the ordering they belonged to had already closed.
3.16.3 Errors #
An "error" message carries error, a human-readable string.
There is no error code and no machine-readable classification. This is a deliberate limit on the interface: an error here is a parse failure, a type mismatch, a timeout, a limit, or a refusal, and a client's response to all of them is the same — show it to whoever wrote the query. A client MUST NOT parse the string, and a collector MAY change the wording of any error at any time.
A collector MUST NOT include in an error message any value the client was not authorized to read (§3.28).
3.16.4 Timeouts #
A collector MUST bound the time a query may take to reach its terminal message.
The clock starts once the request has been decoded and the caller's
token obtained, and it covers everything that follows: parsing, access
checks, cross-type pre-computation, execution, merging, aggregation,
pagination, projection and transmission. A collector MUST send "end",
or for a streaming query "watch", before it expires.
The timeout bounds the initial result set only. Once a streaming
query has sent "watch" its watch phase is not time-limited; what
bounds it instead is the streaming concurrency limit (§3.14), the
distinct-value limit (§3.27), and the client's own ability to keep up
(§3.27).
On expiry a collector MUST cancel the query and send "error". Any
"ok" messages already sent are discarded by the client under the rule
above.
3.16.5 Memory #
A non-aggregating query in the default order (§3.21) needs to hold nothing while it runs: its records can be read in that order and sent as they are read. A collector SHOULD answer it so, and MUST NOT refuse it for the size of its result, however large. A client that wants every record of a large store asks for exactly this.
Other queries must hold something to answer: a sort on another field
holds the records it orders, or with TAKE only the best SKIP + TAKE
of them, and an aggregation holds its groups. A collector MAY bound the
memory queries hold. When a query would exceed the bound, the collector
MUST fail it with "error". It MUST NOT truncate the result, drop
groups, or answer from part of the data instead.
3.17 Value Encoding
Peios / Peios technical reference / PSPU / Observability Interfaces
Every value in a result record is encoded as follows.
| Value | Encoding |
|---|---|
| Integer | MessagePack integer |
| Float | MessagePack float64 |
| Timestamp | MessagePack integer, nanoseconds since the Unix epoch (§3.5) |
| String | MessagePack string |
| GUID | MessagePack string, PCDS canonical form |
| Binary | MessagePack bin |
| Boolean | MessagePack boolean |
| Array | MessagePack array |
| Absent or null | MessagePack nil |
A GUID is rendered as a string rather than as sixteen bytes because a
result record is read by people as often as by programs, and a raw GUID
in a terminal is unreadable. The canonical form is lowercase
8-4-4-4-12 hexadecimal within braces, as PCDS defines it. A query
comparing against a GUID accepts either braced or unbraced input, and
compares case-insensitively (§3.19); a result always uses the canonical
form.
3.17.1 Maps do not appear as values #
An event payload is a MessagePack map, and result records are flat (§3.22). A map in a stored payload is therefore a container to be flattened, not a value to be emitted: its entries become top-level keys of the record, joined by dots, and the map itself never appears.
Arrays are different. An array is emitted as an array value at its flattened path, and a collector MUST NOT traverse into it. Maps nested inside an array are preserved as that array's contents, unflattened and unqueryable.
The asymmetry is deliberate. A map has keys, so its entries have names
that can be addressed, granted access to and indexed. An array has
positions, and a path like hops.3.address would mean something
different in every record — so an array is carried across whole and
treated as one value.
Being one value is also why == against an array field compares the
whole array. The question usually being asked of one — does it hold this
element — is HAS (§3.20).
Binary values in a payload stay binary. A collector MUST NOT render
bin as a string, in either direction.
3.17.2 Missing and null are the same value #
A field absent from an event payload or from a metric's label set encodes as nil, exactly as an explicitly null one does, and the two are indistinguishable in a result record.
This is consistent throughout: they compare equal, they sort together, and they group together (§3.20, §3.21). A collector MUST NOT distinguish them anywhere in the query surface, and a client MUST NOT attempt to.
3.18 The Query Language
Peios / Peios technical reference / PSPU / Observability Interfaces
A query is one string. It names a mode, narrows to some data, and says what to do with it.
EVENTS kacs.* SINCE 1h ago WHERE emitter.process.guid == "550e8400-e29b-41d4-a716-446655440000" TAKE 100
LOGS FROM loregd ERROR ONLY CONTAINING "connection refused" SINCE 1d ago
METRIC cpu.usage[core="0"] SINCE 1h ago AVG_OVER 5m
3.18.1 Three modes #
The first token selects the mode, and a collector MUST reject a query whose first token is not one of them.
EVENTSsearches structured event records, primarily by event type (§3.23).LOGSsearches log output, primarily by origin (§3.24).METRICevaluates measurements, primarily by name and labels (§3.25).
Events and logs are record-oriented: collections you search, returning the records that matched. Metrics are value-oriented: measurements you evaluate, returning numbers computed from samples. The modes differ because the data differs, and forcing all three through one shape would serve none of them.
3.18.2 The primary selector #
Immediately after the mode comes an optional primary selector,
specific to the mode: an event type pattern, FROM with one or more log
origins, or a metric name with an optional label selector. It narrows
the data before anything else runs.
A primary selector MUST NOT be repeated unless its mode defines a list
form — LOGS FROM a, b is one selector naming two origins, not two
selectors.
3.18.3 Clauses #
Everything after the primary selector is a clause, and clauses MAY
appear in any order. EVENTS SINCE 1h ago TAKE 10 and
EVENTS TAKE 10 SINCE 1h ago are the same query.
Order of appearance never affects meaning. Execution follows the fixed sequence below regardless of how the string was written, so a collector MUST NOT derive semantics from clause position.
These clauses work identically in all three modes:
| Clause | Meaning |
|---|---|
SINCE t | Lower time bound, inclusive. |
UNTIL t | Upper time bound, exclusive. Defaults to the evaluation time. |
WHERE p | Filter by a predicate (§3.20). |
WHERE METRIC … / WHERE EVENT … / WHERE LOG … | Filter by a condition on another data type (§3.26). |
SORT f [ASC|DESC], … | Order the results (§3.21). |
TAKE n | Return at most n. |
SKIP n | Discard the first n after ordering. |
STREAM | Deliver matching records as they arrive (§3.27). |
3.18.4 Execution order #
Whatever order the clauses were written in, a collector MUST evaluate them in this sequence:
| Phase | |
|---|---|
| 1 | Cross-type conditions, producing time ranges (§3.26) |
| 2 | The primary selector |
| 3 | Access control on the primary and cross-type sources (§3.28) |
| 4 | SINCE and UNTIL |
| 5 | WHERE, including the ranges from phase 1 |
| 6 | ERROR ONLY and CONTAINING, as WHERE predicates (§3.24) |
| 7 | Metric transforms (§3.25) |
| 8 | GROUP |
| 9 | COUNT BY, TOP N BY, DISTINCT, and the aggregation functions |
| 10 | Metric window aggregations (§3.25) |
| 11 | SORT (§3.21) |
| 12 | SKIP and TAKE |
| 13 | SELECT (§3.22) |
Two positions in that list are load-bearing.
Access control is third, before every filter, aggregate, sort and limit. It is part of the query's logical execution and not a filter applied to the output (§3.28).
SELECT is last. It shapes the output and nothing else; a field it
omits is still available to every earlier phase (§3.22).
3.18.5 Repetition #
A clause MUST appear at most once, and a collector MUST reject a repeat as a parse error, with two exceptions:
WHEREis repeatable. MultipleWHEREclauses are combined withAND, each treated as a parenthesised group:WHERE a == 1 OR b == 2followed byWHERE c == 3means(a == 1 OR b == 2) AND c == 3.SELECTis repeatable where it is valid at all, and is additive:SELECT event.time SELECT event.typenames both fields.
Both exist so that a query can be built up in pieces — by a tool appending a filter, or by a person adding one to a query they already have — without rewriting what is already there.
3.18.6 Counts #
TAKE, SKIP and the N of TOP N BY are unsigned decimal integers
that MUST fit in 64 bits. A negative, hexadecimal, floating-point or
missing count is a parse error.
SKIP defaults to 0. TAKE omitted means no limit. TAKE 0 and
TOP 0 BY are valid, and return no records after every earlier phase
has run — which is not the same as not running the query, because a
TOP 0 BY still counts and a TAKE 0 still enforces access control.
3.18.7 Case #
Keywords are matched case-insensitively, using ASCII case folding, in grammar positions where a keyword is expected. This document writes them in uppercase by convention only.
Identifiers are case-sensitive, except where the language defines a named alias for a value (§3.23).
A word spelled like a keyword MAY be used where the grammar expects an
identifier or a value: LOGS FROM stream selects the origin stream,
while LOGS STREAM enables streaming. A collector MUST resolve the
ambiguity by grammar position and MUST NOT reserve keywords globally.
3.19 Lexical Rules and Literals
Peios / Peios technical reference / PSPU / Observability Interfaces
A query string is UTF-8. Whitespace separates tokens outside quoted strings and is otherwise insignificant.
3.19.1 Identifiers #
An unquoted identifier is ASCII and matches:
[A-Za-z_][A-Za-z0-9_.-]*
Identifiers name fields, payload paths, metric names, label keys, event
type patterns, log origins and value aliases. ., _ and - are
permitted inside one; /, :, whitespace, quotes, brackets,
parentheses, commas and the comparison operators are not.
This grammar is the same one that constrains a metric name and a metric
label key at ingestion (§3.10), which is what makes every stored metric
identifier writable here without quoting. A log origin is the one
exception: it is constrained to a near relative of this grammar (§3.7)
that also admits a leading digit and a producer component such as
/ExecStartPre[0], and an origin using either is written as a quoted
string.
A value that cannot be written as an identifier MUST be written as a quoted string. Quoted forms are accepted anywhere an identifier is — they are never required for a conforming identifier, but a pattern may need one, and a collector holding identifiers stored under an earlier revision must still be able to select them.
3.19.2 Strings #
A string literal is double-quoted UTF-8. The escapes are \", \\,
\n, \r, \t, and \uXXXX for a scalar value in U+0000 to
U+FFFF written as four hexadecimal digits.
A collector MUST reject any other backslash escape as a parse error, and
MUST reject \uXXXX naming a surrogate code point in U+D800 to
U+DFFF. Surrogate pairs are not decoded: a character outside the basic
multilingual plane is written directly as UTF-8, not as two escapes.
3.19.3 Binary #
A binary literal is a lowercase x, a double quote, an even number of
hexadecimal digits, and a closing quote:
WHERE target_sid == x"010500000000000515000000"
Hexadecimal digits inside are case-insensitive. x"" is valid and is
the empty byte string. Whitespace inside the payload, an odd digit
count, and any non-hexadecimal character are parse errors.
A binary literal compares only against MessagePack bin values. A
collector MUST NOT coerce one to a string or a string to one: x"6162"
and "ab" are different values and never compare equal (§3.20).
3.19.4 Integers #
An integer literal is decimal or hexadecimal.
WHERE emitter.class == 2
WHERE access.granted == 0x1F01FF
A decimal integer MAY carry a leading -, in which case it MUST fit in
signed 64 bits; without one it MUST fit in unsigned 64 bits. A
hexadecimal integer is 0x followed by one or more digits, is always
non-negative, and MUST fit in unsigned 64 bits. A leading + is not
valid. An out-of-range literal is a parse error.
3.19.5 Floats #
A float literal is a finite decimal number with an optional leading -
and either a fractional part or an exponent: 42.0, 0.001, 1e6,
-1.25e-3. A token that looks like an integer, such as 42, is an
integer literal and not a float.
Float literals are binary64 and MUST be finite. NaN, Infinity,
-Infinity and any literal that overflows to infinity are parse errors.
A leading + is not valid.
3.19.6 Booleans and null #
true and false are matched case-insensitively with ASCII folding.
NULL, likewise folded, is valid only in IS NULL and
IS NOT NULL. A collector MUST reject field == NULL and
field != NULL as parse errors rather than evaluating them.
3.19.7 Durations #
A duration is an unsigned decimal integer followed immediately by s,
m, h or d — seconds, minutes, hours or days. A zero duration is a
parse error.
3.19.8 Times #
| Literal | Meaning |
|---|---|
<duration> ago | That duration before the evaluation time. |
<duration> hence | That duration after it. |
today | Midnight of the current day, UTC. |
yesterday | Midnight of the previous day, UTC. |
YYYY-MM-DD | Midnight of that date, UTC. |
YYYY-MM-DDTHH:MM:SS | That instant, UTC. |
Absolute literals are a fixed UTC subset. Components MUST be zero-padded
exactly as shown, the date MUST be a valid Gregorian date, hours are
00–23, minutes and seconds 00–59. Leap seconds are not accepted.
Timezone suffixes and fractional seconds are not part of this revision
and MUST produce a parse error.
3.19.9 The evaluation time #
A collector MUST capture the evaluation time once, before execution
begins, and MUST use that one reading for every ago, every hence,
and for an omitted UNTIL, throughout the query — including throughout
the watch phase of a streaming query.
A query that read the clock more than once could produce a range whose end preceded its start, or a window that grew while it was being scanned. One reading makes the effective query range a fixed interval for the life of the query.
SINCE is inclusive, UNTIL is exclusive: the effective query range is
[SINCE, UNTIL). If SINCE is greater than or equal to UNTIL the
query returns no records — which is a successful query with an empty
result (§3.16), not an error. A time literal that evaluates outside the
timestamp domain MUST produce an error (§3.5).
3.20 Comparison and Logic
Peios / Peios technical reference / PSPU / Observability Interfaces
3.20.1 Operators #
| Operator | Meaning | Operand types |
|---|---|---|
== | Equal | any |
!= | Not equal | any |
> >= < <= | Ordering | integer, float, timestamp |
STARTS_WITH | Prefix | string |
ENDS_WITH | Suffix | string |
CONTAINS | Substring | string |
HAS | Array contains an element | array |
IN | Member of a set | any |
NOT_IN | Not a member | any |
IS NULL | Absent or null | any |
IS NOT NULL | Present and not null | any |
IN and NOT_IN take a non-empty parenthesised, comma-separated list
of literals. An empty list is a parse error.
WHERE origin IN ("loregd", "peinit")
WHERE emitter.class NOT_IN (kacs, lcs)
= is not a comparison operator and MUST produce a parse error, with
one exception: inside a metric label selector, where = and == are
both equality (§3.25).
3.20.2 HAS asks about an array #
HAS is true when the field holds an array one of whose elements equals
the value, compared as == compares it, so a binary literal matches a
binary element exactly and a string element folds case.
EVENTS WHERE subject.token.groups HAS x"010200000000000520000000200200"
A payload field MAY hold an array — a token's group SIDs are the
canonical example — and == against an array is whole-value equality:
an array equals only an array of the same length whose elements are
equal in order. That is what an operand of a different type compares
against, so == between an array field and a single element is a type
mismatch and evaluates false, exactly as §3.20's "types do not coerce"
says. HAS is the operator that asks the other question.
HAS applied to a field that is not an array evaluates false for
that record, for the same reason an ordering operator applied to a
non-numeric value does: a payload field's type varies from record to
record. Elements are compared one level deep — an element that is itself
an array is compared whole, not searched.
HAS is not a set operator and MUST NOT be confused with IN. IN
asks whether a scalar field equals one of several literals; HAS asks
whether one array field holds one literal. CONTAINS is substring
matching on a string and has nothing to do with either.
A collector MAY evaluate HAS by scanning: array containment need not
be indexable, and answering correctly without an index is required where
answering quickly is not.
3.20.3 Strings fold case #
Every string comparison — ==, !=, STARTS_WITH, ENDS_WITH,
CONTAINS, HAS, IN, NOT_IN — is case-insensitive, using
ASCII-only folding: bytes A–Z compare equal to a–z, and every non-ASCII
byte compares exactly.
This applies uniformly: to event header fields, to payload fields, to log messages, to metric label values, and to the pattern matching of primary selectors. Integers, floats, GUIDs, timestamps and binary values are unaffected.
3.20.4 Numbers compare mathematically #
Integers and floats compare by mathematical value, not by casting both to one storage type.
An integer equals a finite float only when the float represents exactly
that value. Ordering between an integer and a float MUST be exact,
including for integers outside the range binary64 can represent exactly.
A collector MUST NOT resolve 9007199254740993 > 9007199254740992.0 by
converting the left operand to a float, which would make it false.
3.20.4.1 NaN #
An event payload may carry a NaN (§3.5), though a literal never can
(§3.19). Every NaN is one value: it equals every other NaN and nothing
else, so x == 5 is false and x != 5 is true for a record whose x
is a NaN.
An ordering operator with a NaN on either side evaluates false, as
IEEE 754 has it. x < 5 and x >= 5 are both false for that record.
Sorting and grouping still need a place for a NaN, and §3.21 gives it
one.
3.20.5 Types do not coerce #
Values of different non-numeric types are never equal. The string "1"
is not the integer 1, and != between them is true.
An ordering operator applied to a field whose runtime value is non-numeric evaluates false for that record — not an error, because a payload field's type varies from record to record and a query cannot know in advance.
An ordering operator applied to a known fixed field whose declared
type cannot be ordered is different: a collector MUST reject the query
during parsing or planning rather than executing a predicate that can
never match. WHERE message > 5 is a mistake the collector can see, and
returning zero records for it would be a wrong answer that looks like a
right one.
Binary values compare by exact byte equality under ==, !=, HAS,
IN and NOT_IN. Ordering is not defined for binary values, and a
predicate applying an ordering operator to a binary literal MUST produce
a parse error.
3.20.6 Absent fields #
A field absent from an event payload or from a metric's label set resolves to null (§3.17).
Every comparison against null evaluates false, except IS NULL, which
is true, and IS NOT NULL, which is false. In particular
WHERE field != "x" does not match records lacking the field: a
record with no opinion is not a record with a different opinion.
3.20.7 Combining predicates #
Predicates within one WHERE combine with AND and OR. AND binds
tighter than OR. Parentheses override.
Multiple WHERE clauses combine with AND, each parenthesised as a
group (§3.18).
There is no NOT. Negation is written with the negative operators —
!=, NOT_IN, IS NOT NULL — and a collector MUST reject NOT as a
parse error rather than silently treating it as an identifier.
3.21 Ordering, Grouping and Distinct
Peios / Peios technical reference / PSPU / Observability Interfaces
Two rules govern this article, and both exist for the same reason.
Ordering MUST be total and deterministic. For a fixed set of stored
records, one query MUST produce one order. Without that, SKIP and
TAKE are meaningless: a client paging through results would see
records twice and never see others, and would have no way to tell.
Equality here MUST be the query language's, not the storage engine's. A collector that grouped by whatever its database considers equal would group differently depending on how it was built.
3.21.1 SORT #
SORT orders by one or more fields. Each defaults to ascending; ASC
may be written, DESC reverses that field.
SORT timestamp DESC
SORT origin ASC, timestamp DESC
If the named fields do not uniquely order two records, a collector MUST append internal tiebreakers until the order is total. The tiebreakers are not query-language fields, are never emitted in a result record, and a client MUST NOT depend on their identity — only on their effect, which is that the order is stable.
When no SORT is present:
- Events and logs are ordered by timestamp descending, most recent first — the order a person reading a log wants.
- Metrics are ordered by timestamp ascending, the order a chart wants.
3.21.2 Value ordering #
SORT uses the query language's ordering, not the storage engine's.
Missing fields and explicit nulls are equivalent. Ascending order sorts
by type first, in this order:
- Null
- Boolean,
falsebeforetrue - Numeric, integers and floats compared mathematically (§3.20)
- String and GUID, ASCII-folded
- Binary, unsigned lexicographic
- Array
DESC reverses the whole ordering, type order included.
A NaN is numeric and sorts after every other number, the infinities included. Every NaN is one value, so NaNs tie with each other and fall to the tiebreakers. Without a place for it the order would not be total: a NaN is neither less than, greater than nor equal to any number under IEEE 754, and a sort given such a comparison may produce any order at all, or fail.
Strings and GUIDs compare with the same ASCII folding as predicates. Two strings equal under folding are ordered by their original UTF-8 bytes, so that folding never costs totality. Binary values compare as unsigned bytes. Arrays compare by their canonical MessagePack encoding (§3.5).
Maps do not appear as result values (§3.17) and MUST NOT appear as sort keys.
3.21.3 Grouping #
COUNT BY, TOP N BY, GROUP and DISTINCT use query-language
equality:
- missing and null are one group
- integers and floats that are numerically equal are one group
- every NaN is one group
- strings and GUIDs group under ASCII folding
- binary values group by exact bytes
3.21.4 The canonical representative #
When a group's members are equal under those rules but not
byte-identical — "Loregd" and "loregd", or 1 and 1.0 — the value
emitted for the group MUST be its canonical representative:
| Group | Representative |
|---|---|
| Null | nil |
| Boolean | the boolean |
| Numeric | an integer if every contributing value was an integer; otherwise a float64 |
| String or GUID | the smallest original UTF-8 byte sequence among the members |
| Binary | the exact value |
| Array | the member with the smallest canonical MessagePack encoding |
Choosing the smallest rather than the first makes the representative a property of the set, independent of the order records were read in — which matters because a collector may read them from several places at once and merge (eventd TRMP §6.4).
3.21.5 Ordering of aggregates #
COUNT BY and GROUP … COUNT results are ordered by count
descending. Ties are broken by the group key under the value ordering
above — for a GROUP of several fields, each key in turn in the order
the fields are named — then by the representative's encoded bytes.
GROUP … SUM, AVG, MIN and MAX results MUST be ordered by their
group keys ascending under the value ordering above, comparing each key
in the order the fields are named. This order applies before SKIP and
TAKE. An explicit SORT overrides it; groups tied under that sort
retain their group-key order.
TOP N BY is exactly COUNT BY with TAKE N applied after that
ordering.
DISTINCT results are ordered by the distinct value under the value
ordering, unless an explicit SORT overrides it.
3.22 Fields and Results
Peios / Peios technical reference / PSPU / Observability Interfaces
Every result record is a flat MessagePack map. There is no nesting in a result, in any mode.
Flatness is what makes one set of rules — for access control, for ordering, for grouping, for projection — apply uniformly to a header field, a payload field and a metric label alike. A nested result would need a path language, and a path language would need to be reproduced identically by every SD author and every client.
3.22.1 Event fields #
These names resolve to header fields, by the mapping of PGSS §6.4:
event.time, event.sequence, event.cpu, event.type,
event.boot.guid, emitter.class, emitter.token.guid,
emitter.true-token.guid, emitter.process.guid
Every other name resolves to a payload field.
3.22.1.1 Header names are reserved #
Header field names are reserved in the query language and in result maps. If a payload carries a value whose flattened path is a header field's name, the header wins.
The colliding payload value is stored unchanged, and is retrievable as
part of the raw payload by whatever holds it, but it is not exposed
through field resolution, SELECT, WHERE, aggregation, access control
or result maps. If the payload carries a map at a header field's path,
the map's entire subtree is suppressed in the same way.
Only the paths themselves are reserved. A payload field beside them,
such as emitter.process.pid, resolves normally. An emitter MUST NOT
write a value at a header path (PGSS §6.4).
3.22.1.2 Flattening #
Payload maps are flattened recursively, path segments joined with .:
a payload {source: {name: "x"}} exposes the field source.name.
Each map key on a queryable path MUST be a MessagePack string matching:
[A-Za-z_][A-Za-z0-9_-]*
Note that . is not permitted in a segment, though it is permitted
in an identifier generally (§3.19) — a key containing a dot could not be
distinguished from a path through two maps.
A key that is not a string, contains ., or does not match the grammar
is stored unchanged and is not queryable: it does not resolve, does
not appear in a result map, and has no field identity for access
control. An empty map produces no field at all.
Maps are containers; every non-map value, arrays included, is emitted at its flattened path (§3.17).
If two payload entries flatten to the same path, the first in MessagePack map order wins and later duplicates are suppressed.
3.22.2 Log fields #
timestamp, origin, is_error, message, boot_id, job_id
The set is closed. There are no payload fields and no flattening, and a collector MUST reject any other log field name as a parse error rather than resolving it to null. A log record has a fixed shape, so a name outside it is a mistake the collector can see — unlike an event payload field, which may legitimately be absent from a given record.
is_error is a boolean in the query language, and compares against
true/false or against 1/0.
3.22.3 Metric fields #
timestamp, boot_id, name, type, value, overflow
Every other name resolves to a label. Ingestion refuses labels colliding with these six (§3.10), so the flat namespace is unambiguous by construction rather than by a precedence rule.
type is the series type as a string: "counter", "gauge" or
"histogram".
overflow is an output-only boolean marker. It is present and true only
when a histogram percentile cannot be located below the series' highest
boundary (§3.25); otherwise it is absent. Predicates and label selectors
therefore see it as null, like any other output not yet computed.
3.22.3.1 Types belong to the query mode #
Header aliases and GUID literal forms apply only where a name is a
header in that query mode. A metric label named emitter.class remains
a string label: "kacs" is not the event-header class number. Likewise,
a metric label named job_id or an event payload field named boot_id
does not acquire the log header's GUID type. Ordinary string comparison
rules apply, including to bracket selectors and cross-type metric
selectors. The actual GUID headers still accept braced and unbraced
input (§3.17).
3.22.4 What a record contains #
Event records carry the header fields plus every non-suppressed flattened payload field, as top-level keys.
Log records carry the log fields.
Raw metric sample records carry timestamp, boot_id, name,
type, value, and the series' labels as top-level keys. A
percentile-overflow result additionally carries overflow.
Aggregated metric results carry name, type and value, plus
labels when the result belongs to one label set. They carry boot_id
only when the query restricted the samples to exactly one boot by a
boot_id equality predicate, in which case the value is that boot ID; a
result that could span boots omits it rather than picking one. An
aggregate tainted by a percentile overflow additionally carries
overflow (§3.25).
Aggregation results in event and log mode carry the group key fields and the aggregate output, with the fixed schemas of §3.23.
3.22.5 SELECT #
SELECT narrows a result record to the named fields. It is valid only
for non-aggregating event and log queries.
A collector MUST reject SELECT combined with COUNT BY, TOP N BY,
DISTINCT or GROUP, and MUST reject it in metric mode: all of those
have fixed output schemas, and a clause that reshapes a fixed schema is
a contradiction rather than a refinement.
SELECT is applied last, after every other phase (§3.23). It
controls the shape of the output and nothing else: a field not selected
is still available to WHERE, to SORT, and to grouping. Narrowing
what is displayed MUST NOT narrow what is filtered on.
3.23 Event Queries
Peios / Peios technical reference / PSPU / Observability Interfaces
EVENTS [type_pattern] [clauses…]
3.23.1 The type pattern #
The primary selector is an optional event type pattern, placed
immediately after EVENTS.
EVENTS kacs.audit.access.checked -- exactly that type
EVENTS kacs.* -- every type beginning "kacs."
EVENTS *.rejected -- every type ending ".rejected"
EVENTS kacs.*.changed -- kacs.audit.descriptor.changed, kacs.caap.policy.changed, …
EVENTS -- every type
* is the only metacharacter, and matches zero or more of any
character, dots included. ?, [ and { have no special meaning and a
collector MUST NOT treat them as any. Matching folds case, like every
string comparison (§3.20).
A pattern with no * is exactly WHERE event.type == "…". A pattern
whose only * is trailing is exactly
WHERE event.type STARTS_WITH "…". Anything else is a glob.
3.23.2 Origin class aliases #
emitter.class, the origin class, accepts named aliases as well as its
integer values:
| Alias | Value |
|---|---|
userspace | 0 |
kmes | 1 |
kacs | 2 |
lcs | 3 |
EVENTS WHERE emitter.class == kacs SINCE 1h ago
These are the only aliased values in the language. A collector MUST accept both forms and MUST treat them as identical.
3.23.3 Aggregation #
Grouping equality, canonical representatives and tie ordering are
defined in §3.21. Every aggregation below has a fixed output schema,
and rejects SELECT (§3.22).
3.23.3.1 COUNT BY #
Counts records grouped by one field, ordered by count descending.
EVENTS SINCE 24h ago COUNT BY event.type
Output: {<field>: representative, count: <unsigned integer>}.
3.23.3.2 TOP N BY #
COUNT BY with a limit — the N most frequent values.
EVENTS SINCE 1h ago TOP 10 BY emitter.process.guid
Output: the COUNT BY schema.
3.23.3.3 DISTINCT #
The distinct values of one field.
EVENTS SINCE 24h ago DISTINCT event.type
Output: {<field>: representative}.
3.23.3.4 GROUP #
Groups by one or more fields, followed by an aggregation function:
COUNT, or SUM, AVG, MIN, MAX with a field argument.
EVENTS SINCE 1h ago GROUP emitter.class COUNT
EVENTS SINCE 1h ago GROUP emitter.class, event.type COUNT
EVENTS SINCE 1h ago GROUP event.type AVG operation.duration
Output, for GROUP a, b:
| Query | Record |
|---|---|
COUNT | {a, b, count} |
SUM x | {a, b, sum} |
AVG x | {a, b, avg} |
MIN x | {a, b, min} |
MAX x | {a, b, max} |
Group-key fields carry canonical representatives (§3.21).
3.23.3.5 What is aggregated #
For SUM, AVG, MIN and MAX, records whose field is null or
non-numeric are excluded from the aggregate — not treated as zero.
COUNT counts every record regardless. If no record in a group
contributes a numeric value, the group's aggregate is null and the group
is still present, because COUNT of it is still meaningful.
3.23.3.6 Result types #
COUNTreturns an unsigned integer.SUMover integers returns an integer when the exact mathematical sum fits in signed or unsigned 64 bits. If it does not, or if any input was a float, it returns afloat64. If that would be non-finite, the query MUST fail with an error rather than returning an infinity.AVGreturns afloat64whenever at least one numeric value contributed.MINandMAXreturn the winning value itself, under exact numeric comparison. When an integer and a float tie, the integer wins.
3.23.4 Ordering #
Without SORT, results are ordered by event.time descending, ties
broken as §3.21 requires.
3.23.5 INDEX #
EVENTS INDEX target_sid
INDEX asks the collector to prioritise a field for query
acceleration immediately, rather than waiting for it to be observed
often enough to be prioritised automatically. It exists for incident
response, where the field that suddenly matters has never been queried
before.
INDEX is an administrative operation, not a query. It returns no
records. A collector MUST check the caller's token against a Security
Descriptor governing administration of the collector — one distinct from
the read-path descriptors of §3.28 — and MUST refuse a caller that does
not hold it. A collector without such a descriptor MUST refuse INDEX
outright.
A collector MAY treat INDEX as advisory and MAY decline the request,
shed the acceleration later, or do nothing at all. It is a hint about
priority; the accelerations a collector maintains are its own business,
and a conforming collector that maintains none accepts INDEX and has
nothing to do.
There is no command to undo it, because there is nothing to undo: a collector reconsiders its own accelerations continuously and the hint decays with disuse.
3.24 Log Queries
Peios / Peios technical reference / PSPU / Observability Interfaces
LOGS [FROM origin[, origin…]] [ERROR ONLY] [CONTAINING "text"] [clauses…]
Log mode has three primary selectors rather than one, all optional and
all combinable. Each is sugar for a WHERE predicate, and each exists
because it is the thing a person actually types.
3.24.1 FROM #
Selects by origin. Several may be listed, comma-separated.
LOGS FROM loregd
LOGS FROM loregd, peinit
LOGS
FROM is exactly WHERE origin == "…" for one origin and
WHERE origin IN ("…", "…") for several.
Origins are written as identifiers (§3.19) or as quoted strings. An
origin that names a producer within a service, such as
jellyfin/HealthCheck or jobs/<guid>, is not an identifier and is
written quoted (§3.7):
LOGS FROM "jellyfin/ExecStartPre[0]"
3.24.2 ERROR ONLY #
Selects lines that came from standard error.
LOGS ERROR ONLY
LOGS FROM loregd SINCE 1h ago ERROR ONLY
It is exactly WHERE is_error == true, and like every clause it may
appear anywhere after LOGS without changing the meaning (§3.18).
3.24.3 CONTAINING #
Selects lines whose message contains the given text — a substring match, folding case like every string comparison (§3.20).
LOGS CONTAINING "connection refused"
LOGS FROM loregd CONTAINING "failed to open"
It is exactly WHERE message CONTAINS "…".
CONTAINING is a log-specific keyword because searching text is the
primary operation on log data, and the primary operation deserves the
shortest spelling. It is a substring scan, not an indexed text search: a
collector MUST NOT restrict what it matches, and combining it with
SINCE is what keeps it affordable.
3.24.4 Projection and aggregation #
SELECT narrows non-aggregating results to named log fields, and is
additive across clauses (§3.22).
COUNT BY, TOP N BY, DISTINCT and GROUP work exactly as in event
mode (§3.23), with the same fixed output schemas, the same result types,
and the same prohibition on combining them with SELECT.
LOGS SINCE 1h ago COUNT BY origin
LOGS SINCE 1h ago TOP 5 BY origin
3.24.5 Ordering #
Without SORT, results are ordered by timestamp descending, ties broken
as §3.21 requires.
3.24.6 No payload fields #
Log mode has a closed field set (§3.22). A collector MUST reject an
unknown log field name as a parse error, in a WHERE, a SORT, a
SELECT or a grouping clause alike.
This differs from event mode, where an unknown name is a payload field that resolves to null. The difference is that a log record's shape is fixed and known: a name outside it cannot be a field that this record happens to lack, so treating it as null would answer a question the client did not ask.
3.25 Metric Queries
Peios / Peios technical reference / PSPU / Observability Interfaces
METRIC name[label_selector] [transform] [aggregation] [clauses…]
Metric mode evaluates rather than searches. A collector MUST reject
SELECT in metric mode: the result schemas are fixed (§3.22).
3.25.1 Selecting series #
The primary selector is a metric name, optionally followed by a label
selector in brackets. The name supports * with the same glob semantics
as an event type pattern (§3.23).
METRIC cpu.usage
METRIC cpu.*
The brackets — present, absent, or present and empty — decide how multiple matching series are handled, and this is the distinction that governs the rest of the mode.
No brackets — aggregate. Every matching series is combined into one result.
METRIC cpu.usage -- average across all cores
METRIC cpu.usage MAX -- maximum across all cores
Empty brackets — break out. Each series is returned separately.
METRIC cpu.usage[] -- latest value per core
METRIC cpu.usage[] SINCE 1h ago -- a time series per core
Filled brackets — select. Only series matching the label predicates.
METRIC cpu.usage[core="0"]
METRIC cpu.usage[core="0", host="srv1"]
METRIC disk.usage[device STARTS_WITH "sd"]
Label predicates are comma-separated and combined with AND. They use
the operators of §3.20, and within a label selector = is accepted as
equality alongside ==. Label keys are identifiers; values are
identifiers or quoted strings. An absent label resolves to null, so
[device IS NULL] selects the series that carry no device label.
3.25.2 Homogeneity #
After the name, the label selector, WHERE predicates and access
filtering have been applied, the remaining series MUST be of one
type. A collector MUST reject a selection spanning more than one type
at execution time, with an error asking for a narrower name or an
explicit WHERE type == ….
A selection resolving to zero series returns no records — a successful query with an empty result, not an error.
The rule exists because every function below is defined on one type. A selection mixing counters and gauges has no meaningful rate, and a selection mixing either with histograms has no meaningful value at all.
3.25.3 Function stages #
Function keywords execute in fixed stages regardless of where they were written:
- Transform —
RATE,DELTA,P50,P95orP99. Operates within each series independently and produces scalars. At most one per query. - Terminal aggregation — either a scalar aggregation (
AVG,MIN,MAX,SUM) or a window aggregation (AVG_OVER,MIN_OVER,MAX_OVER,SUM_OVER). At most one per query; specifying both a scalar and a window aggregation is a parse error.
The pipeline ordinarily operates on scalars throughout. Counter and gauge
samples are already scalar; a histogram sample is not scalar until a
percentile function has been applied. The sole exceptional result is a
tagged percentile overflow, whose propagation is defined below. A collector
MUST therefore reject, at
execution time when the type is known, a query that resolves to a
histogram series without a percentile function, or that applies RATE,
DELTA, or any scalar or window aggregation directly to one.
Every numeric output is a finite binary64. If any computation would produce NaN or an infinity, the query MUST fail with an error rather than returning it. The null in a tagged percentile-overflow result is not a numeric output.
3.25.4 Transforms #
3.25.4.1 RATE and DELTA #
DELTA is the change between consecutive samples; RATE is that change
per second. Both apply only to counter series, and a collector MUST
reject them on a gauge or histogram at execution time.
Both use the same pair construction. Samples of one series are taken in
ascending timestamp order, with a deterministic tiebreaker among samples
sharing a timestamp. Each consecutive pair (s1, s2) whose s2 falls
inside the effective query range, and where s2 is later than s1,
produces one scalar at s2's timestamp. The immediately preceding
sample before the first in-range one MUST be used as s1 for the
first pair, when such a sample exists — without it the first point of
every range would be missing, and a chart would show a notch at the
start of every window.
The adjusted delta is s2 - s1 when the value rose, and s2 alone when
it fell, because a fall means the counter restarted from zero. RATE is
that adjusted delta divided by the elapsed seconds. A pair with
non-positive elapsed time contributes nothing.
METRIC http.requests.total SINCE 1h ago RATE
METRIC http.requests.total SINCE 1h ago DELTA
3.25.4.2 P50, P95, P99 #
Percentiles of histogram series only; a collector MUST reject them on a counter or gauge at execution time. Each histogram sample yields one value.
Evaluation is nearest-rank over the sample's cumulative counts: for
percentile q, compute rank = ceil(q × total_count), and take the
first boundary whose cumulative count is at least rank.
A sample with total_count == 0 yields no record. A sample whose rank
falls above the final cumulative count — meaning the percentile lies
in the overflow region above the highest boundary — yields a record with
value null and overflow true. The distribution proves that the
percentile exceeds the highest boundary but does not record a finite value
for it. overflow is absent from ordinary percentile results.
METRIC request.duration P95
METRIC request.duration[origin="loregd"] SINCE 1h ago P99
3.25.5 Scalar aggregations #
AVG, MIN, MAX and SUM reduce scalars to one value. They MUST NOT
be applied to a histogram series directly.
What they aggregate over depends on the brackets:
- Bracketed, so one result per series: over time, within each series.
- Unbracketed without
SINCE: over the latest transformed value of each matching series. The result timestamp is the greatest of the contributing timestamps. A series that cannot produce a value — aRATEwith fewer than two samples, say — contributes nothing. - Unbracketed with
SINCE: valid only when the selector resolves to zero or one series. More than one MUST be rejected with an error asking for a window aggregation.
METRIC cpu.usage AVG
METRIC http.requests.total RATE SUM
METRIC cpu.usage[] SINCE 1d ago AVG
METRIC cpu.usage[core="0"] SINCE 1h ago MIN
The unbracketed default aggregation, when no SINCE and no explicit
function is given, is AVG. No implicit scalar aggregation is added
when a window aggregation is present.
If nothing contributes to an aggregation, the query returns no record
for that output group. Otherwise the output timestamp is the greatest
contributing timestamp — for RATE and DELTA, the later sample of the
contributing pair.
If any contributing percentile result has overflow true, a scalar
aggregation MUST conservatively return value null and overflow true.
It MUST NOT ignore the result and compute from the remaining finite values.
3.25.5.1 Why unbracketed plus SINCE needs a window #
A collector MUST NOT synthesise a merged time series from samples that do not share timestamps.
Two series sampled at unrelated moments cannot be averaged point by point without inventing values between the points, and interpolation would make the collector responsible for a number nobody measured. A window aggregation supplies the common time grid explicitly, which is why it is required rather than assumed.
3.25.6 Window aggregations #
AVG_OVER, MIN_OVER, MAX_OVER and SUM_OVER take a duration and
produce one value per window. They require SINCE; a collector MUST
reject a window aggregation without one as a parse error.
Windows are fixed and aligned to Unix-epoch multiples of the duration — not to the query's start — so that the same window boundaries fall in the same places for every query. The output timestamp is the window start. Windows with nothing in them are omitted rather than emitted as null.
METRIC cpu.usage SINCE 1d ago AVG_OVER 1h
METRIC cpu.usage[] SINCE 1d ago AVG_OVER 5m
METRIC http.requests.total SINCE 1h ago RATE SUM_OVER 5m
METRIC request.duration P95 SINCE 1h ago AVG_OVER 5m
AVG and AVG_OVER are different keywords and a collector MUST NOT
treat them as synonyms: AVG produces one value for the range,
AVG_OVER one per window.
A collector MAY cache aligned, complete window results as an implementation optimisation. Such a cache MUST be transparent: freshness must be proved from the authoritative samples before reuse, partial windows must retain their ordinary semantics, and a missing, stale or disabled cache MUST produce exactly the same result as direct evaluation.
For raw and percentile-transformed values, a window contains the scalars whose timestamps fall inside it, and the function is applied to those. No interpolation is performed.
If a window contains a percentile result with overflow true, its output
MUST also carry value null and overflow true. This propagation applies
again when per-series window results are combined.
For RATE and DELTA with a window aggregation, each series first
produces at most one scalar per window: the window DELTA is the
sum of reset-adjusted deltas for pairs whose later sample is in the
window, and the window RATE is that divided by the elapsed seconds
those pairs covered. The preceding-sample rule applies to the first pair
of each window. The terminal aggregation then combines the per-series
window values — so RATE SUM_OVER 5m sums the series' five-minute
rates, and RATE AVG_OVER 5m averages them. Where the selector resolves
to exactly one series, all four window functions return that series'
window value.
Bracketed window queries keep labels in the result rows. Unbracketed ones omit them, unless the selector resolved to exactly one series.
3.25.7 Without SINCE #
With no SINCE, the query returns the latest value.
METRIC cpu.usage[core="0"]
METRIC cpu.usage[]
METRIC cpu.usage
"Latest" is per series, by timestamp with the deterministic tiebreaker.
For RATE and DELTA, it is the latest valid consecutive pair with
positive elapsed time; a series with no such pair returns nothing.
3.25.8 Boot filtering #
Samples carry boot_id but series continue across boots (§3.13). A
query MAY restrict to one boot:
METRIC cpu.usage[] WHERE boot_id == "550e8400-e29b-41d4-a716-446655440000"
A boot-filtered metric query MUST be evaluated from raw samples. A collector MUST NOT serve one from any pre-computed aggregate that is not itself partitioned by boot.
3.25.9 Results #
One record per raw sample; one per valid pair for RATE and DELTA,
timestamped at the later sample; one per non-empty histogram sample after
a percentile transform, including an explicit overflow record; one per
window for window aggregations; one for a scalar aggregation.
A finite histogram result carries only the percentile in value. An
overflow result carries value null and overflow true. The boundaries,
counts, total and sum are not returned by the query language in this
revision, in any mode.
{timestamp: 1714000000000000000, boot_id: "{550e8400-…}", name: "cpu.usage", type: "gauge", core: "0", value: 42.7}
{timestamp: 1714000300000000000, name: "cpu.usage", type: "gauge", core: "0", value: 39.8}
Without SORT, metric results are ordered by timestamp ascending
(§3.21) — the opposite of events and logs, because a metric result is
read as a series rather than as a list of occurrences.
3.26 Cross-Type Filtering
Peios / Peios technical reference / PSPU / Observability Interfaces
A cross-type filter narrows one data type by a condition on another. It is the only correlation mechanism in the language; there is no join.
EVENTS kacs.* SINCE 1h ago WHERE METRIC cpu.usage[core="0"] > 80
LOGS FROM loregd SINCE 1h ago WHERE EVENT kacs.audit.access.checked EXISTS
METRIC cpu.usage[] SINCE 1h ago WHERE EVENT eventd.store.quarantined EXISTS
EVENTS kacs.* SINCE 1h ago WHERE LOG loregd CONTAINING "error" EXISTS
| Form | Available in |
|---|---|
WHERE METRIC … | events, logs |
WHERE EVENT … EXISTS | logs, metrics |
WHERE LOG … EXISTS | events, metrics |
3.26.1 How it is evaluated #
A collector MUST evaluate the cross-type condition first, producing the set of time ranges over which it holds, and then apply those ranges as additional timestamp bounds on the primary source.
The condition is evaluated against the referenced data's own resolution — the metric's sample interval, or the density of matching events — and not once per record of the primary source. It is computed once for the query.
3.26.2 Metric conditions #
WHERE METRIC operates on raw scalar samples of counter and gauge
series only. Transform, scalar aggregation and window aggregation
keywords are not valid in one, and a condition resolving to a histogram
series MUST be rejected.
The selector MUST resolve to zero or one series. Zero produces no true ranges. More than one MUST be rejected with an error asking for a bracketed or narrower selector.
Within the effective query range, a sample's value is treated as active
over [sample.timestamp, next_sample.timestamp), clipped to the range,
and the final sample stays active through the upper bound. A collector
MUST include the latest sample before SINCE as the initial state
when one exists; without it the condition would be false from the start
of every range until the first sample inside it, which for a
fifteen-second sampling interval is fifteen seconds of wrongly excluded
records. If no earlier sample exists, the condition is false until the
first in-range sample.
Samples sharing a timestamp are ordered deterministically; the earlier ones create zero-width intervals and the last at that timestamp is the active value.
This is interpolation of a kind, and it should be understood as such: it assumes the condition held continuously between two samples. A metric that crossed a threshold and crossed back between samples is invisible.
3.26.3 Existence conditions #
WHERE EVENT … EXISTS and WHERE LOG … EXISTS are true when at least
one matching record lies near the primary record in time. The event type
supports * globbing (§3.23); the log form names an origin and
optionally a CONTAINING text.
"Near" is a centred half-open window of a configured width W. With
lower = floor(W / 2) and upper = W - lower, the condition is true
for a primary timestamp t when a matching record exists with:
timestamp >= t - lower
timestamp < t + upper
Equivalently, a matching record at e contributes the true range
[e - lower, e + upper). When W is odd the extra nanosecond falls on
the upper side, so the width is exactly W and never W ± 1.
3.26.4 The lookback limit #
A collector MUST bound how far back a cross-type filter may scan.
If the effective query range exceeds the limit, a collector MUST reject
the cross-type filter with an error saying the range is too large, and
the error SHOULD suggest narrowing it with SINCE or UNTIL.
A query with a cross-type filter and no SINCE MUST be rejected. An
unbounded cross-type scan is never permitted, in any mode, at any
configured limit.
The reason is that a cross-type filter reads a second store in full before the first query begins. Its cost is set by the referenced data's density, which the client did not select and cannot see, so a query that looks cheap can scan a hundred times more than it returns.
3.26.5 Cost #
A cross-type filter is efficient when it is selective — narrow true ranges eliminating most of the primary source — and expensive when it is broadly true, which is the case where it also eliminates nothing. A condition that holds across the whole range costs the full scan of both stores and returns exactly what the query would have returned without it.
3.27 Streaming
Peios / Peios technical reference / PSPU / Observability Interfaces
STREAM turns a query into a live tail. It is a flag, may appear
anywhere in the string, and takes no argument.
Streaming is available for event and log queries only. A collector
MUST reject STREAM in metric mode.
3.27.1 The shape of a streaming query #
- The collector executes the query normally and sends the initial
result set as
"ok"messages. - It sends
"watch"(§3.16). The query is established at this point and not before. - It stays open. As records are committed, it evaluates them against the query and sends those that match.
- It continues until the client disconnects, an error terminates it, or the collector shuts down.
There is no "end" message for a streaming query, ever.
3.27.2 What may be streamed #
Raw record queries and DISTINCT queries. A collector MUST reject
STREAM combined with COUNT BY, TOP N BY or GROUP as a parse
error — those produce one answer about a set, and a set that is still
growing has no answer yet.
A collector MUST reject STREAM combined with UNTIL. An upper time
bound and an unbounded live tail are contradictory requests.
SINCE is permitted and applies to both phases, resolved against the
evaluation time captured at query start (§3.19).
3.27.3 What still applies during the watch phase #
Access control, the primary selector, the SINCE bound and every
WHERE predicate — cross-type conditions included — are evaluated
against each new record.
SORT, TAKE and SKIP apply to the initial result set only.
Streamed records are delivered in commit order and a collector MUST NOT
reorder, limit or skip them: there is no total order over records that
have not arrived, and applying TAKE to a stream would silently end it.
SELECT applies to streamed records as it does to initial ones.
3.27.4 DISTINCT streaming #
EVENTS kacs.* DISTINCT emitter.process.guid STREAM
LOGS DISTINCT origin STREAM
A DISTINCT stream emits a value the first time it is seen, and never
again. The output schema is DISTINCT's fixed one (§3.23) in both
phases.
The initial result set is the complete distinct set visible at query start, after access control and every filter. The collector then holds a seen set initialised from it. Each newly committed record that passes access control and the filters is reduced to its value for the field, and emitted only if that value is not already in the seen set under the grouping equality of §3.21; emitted values are then added.
A collector MUST bound the seen set.
If initialising the set or inserting a value would exceed the bound, the collector MUST terminate the query with an error. It MUST NOT evict: "not seen before" is the entire meaning of the output, and a set that forgets would re-emit values it had already reported, which is worse than stopping.
A collector MUST reject DISTINCT … STREAM combined with SORT, TAKE
or SKIP, so that the seen set always corresponds to the complete
initial visible set. SELECT is already invalid with DISTINCT
(§3.22).
3.27.5 Cross-type conditions during the watch phase #
The pre-computed time ranges of §3.26 describe the past. A collector MUST NOT reuse them for streamed records.
For a metric condition, the selector has already been required to resolve to exactly one series (§3.26). For each committed batch, the collector finds that series' active sample at the batch's latest candidate timestamp under §3.26's interval rules and evaluates the condition against it. If no sample is active there, the condition is false. A false condition filters out the whole batch; a true one leaves the batch to be filtered by the remaining predicates as usual.
For an existence condition, the collector applies §3.26's centred window to each candidate record's own timestamp. These are evaluated per record, not per batch, because a matching record may be near some of a batch and not the rest.
3.27.6 Backpressure #
If a client cannot keep up, the collector MUST drop the query rather than buffer for it.
Backpressure is detected on the socket send buffer: when a result message cannot be sent because the buffer is full, the collector MUST terminate the query immediately and MUST NOT block on the send. It sends an error if the socket will still take one, and closes otherwise.
Streaming MUST NOT slow or block ingestion. A streaming client is the lowest-priority consumer of a collector's time, and a slow one is disconnected rather than accommodated — the same principle as §3.4, applied on the way out.
3.27.7 Latency #
Delivery latency is bounded below by the collector's commit interval for the store concerned, because a record is only streamable once it is committed. A client that needs lower latency than that is not served by this interface: the KMES ring buffer is the lower-latency path and is specified in PSPK.
3.28 What a Client Cannot See
Peios / Peios technical reference / PSPU / Observability Interfaces
A collector MUST enforce read access on every query, against the token captured when the client connected (§3.14).
How it does so is its own design, and the mechanism the mainline collector uses is described in the eventd TRMP. What this chapter fixes is the part a client can observe: which results it gets, and what it is told about the ones it does not.
3.28.1 The unit of access is the concrete identifier #
Access is resolved per concrete identifier — the event type, log origin or metric name a stored record actually carries (§3.2) — and not per query, per store, or per pattern the query happened to write.
A collector MUST resolve each identifier that a query's data could touch
independently. A broad selector authorizes nothing by itself: EVENTS
with no pattern, EVENTS kacs.*, LOGS with no FROM, and
METRIC cpu.* are all resolved identifier by identifier, and a client
permitted to read one matching identifier and not another sees only the
first.
Identifiers are matched to rules by dot-delimited prefix, most specific
first, falling back to a wildcard default: for
kacs.audit.access.checked, a rule for kacs.audit.access.checked,
then one for kacs.audit.access, then kacs.audit, then kacs, then
the default.
A collector MUST fail closed. If no rule resolves — including because the default is missing — access is denied.
3.28.2 Filtering is silent #
Records and fields removed by access control are removed without comment. A collector MUST NOT indicate in a response that anything was withheld, and a client MUST NOT assume a result set is complete.
The consequences are precise and a client needs all of them:
- A record whose identifier the client may not read is absent, not redacted.
- A field the client may not read is absent from the record, and is indistinguishable from a field the record never carried (§3.17).
COUNT,COUNT BY,TOP N BY,DISTINCTand every aggregation reflect only authorized records. A count is a count of what the client may see.- A cross-type condition referencing data the client may not read evaluates as though no matching data exists (§3.26). It does not fail the query.
TAKEandSKIPpage over the authorized records only.
3.28.3 Access control runs before everything #
A collector MUST remove unauthorized records from the logical row set before predicates, transforms, grouping, aggregation, sorting, pagination and projection (§3.18).
This is not tidiness. Counting, ordering or paginating over records a client may not read leaks them through the count, through the ordering, and through the gaps in pagination — a client could establish how many records of a type it cannot read exist, and roughly when, without ever seeing one.
A collector MAY reach the result however it likes: pushing the authorization down into its storage engine, or reading candidates and discarding them before aggregating. What it MUST NOT do is produce a different answer from the one filtering-first produces.
3.28.4 Denied fields do not fail the query #
When a query references a field in a predicate, a grouping, a sort or an aggregation, and some matching identifier does not grant that field, the records under that identifier contribute nothing — exactly as if their identifier had been denied outright.
A collector MUST NOT reject the query.
Authorization for a field is resolved from the field as written, against each concrete identifier, and does not depend on whether any record of that identifier actually carries it. Payload fields vary between records of the same type, so a rule that turned on presence would be undecidable before the scan it was meant to authorize.
3.28.5 What is not a field #
Derived aggregate outputs — count, sum, avg, min, max — are
not source fields, have no access identity of their own, and are
visible whenever the client is authorized for the records and the source
fields they were computed from.
Values internal to preserving query semantics — row identifiers, series
identifiers, ordering tiebreakers, series type checks — are likewise not
query-language fields (§3.21). A metric result's value is a source
field, because it is a raw sample or a scalar derived from raw samples.
3.28.6 Errors say nothing #
A collector MUST NOT include a value the client is not authorized to read in any error message (§3.16), including in errors raised by internal consistency checks.
3.28.7 Streaming #
Access decisions made for the initial result set are reused during the watch phase, but a collector MUST resolve any new concrete identifier that appears in a streamed batch and check it before using the record or its distinct value — a new event type or a new log origin appearing mid-stream has never been authorized.
If a rule changes during a streaming query, a collector MUST re-check subsequent batches against the new rule.
The token does not change. It was captured at connection (§3.14), so a client whose group memberships change mid-stream continues to be evaluated against what it connected with, and a client whose access is revoked keeps receiving records until it disconnects.
3.28.8 What write authentication proves #
Log origin is broker-attested. The service manager chooses it from
the pipe being read, and the log socket excludes service processes
(§3.6). It proves which service context the manager associated with the
line; it does not prove that the line's text is true.
Metric name is publisher-authorized. KACS attests the effective token
that sent the datagram and the collector verifies EVENTD_PUBLISH for
that name (§3.9). Labels and values remain producer statements. A
publisher authorized for http.requests can report any value and any
valid label set for that name.
Read authorization is independent. Permission to publish an identifier does not imply permission to query it, and permission to query it does not imply permission to publish it.
3.29 Extension
Peios / Peios technical reference / PSPU / Observability Interfaces
There is no version number on any of the three interfaces. No datagram carries one, no query message carries one, and there is no exchange in which either party could state or discover what the other speaks.
That is a deliberate consequence of the shapes chosen, and it is worth being explicit about, because it means every rule below is the only mechanism available.
Ingestion is one-way over a datagram socket: there is no reply in which a collector could announce a version and no state in which a producer could remember one. The query channel could carry a version — it is a stream, and it has a request message — and does not, because a version field is only useful if a party may then behave differently, and a client cannot usefully vary: it either asks a question the collector understands or does not.
What replaces negotiation is a set of rules under which both sides may change without either being told.
3.29.1 Unknown fields are ignored #
A collector MUST ignore fields it does not recognise in a log record (§3.7), in a metric record (§3.11), and in a query request (§3.15).
This is what allows a field to be added. A producer built against a later revision may send a field this collector has never heard of, and the record is still stored; a producer built against an earlier one omits a field that has since been added, and the record is still stored because everything added is optional.
A field added to any of these three maps MUST therefore be optional, and a collector MUST NOT require one to be present.
3.29.2 Unknown values are refused, not ignored #
The rule does not extend to values.
An unrecognised type in a metric record discards the record (§3.12); a
first token that is not a mode fails the query (§3.18); an unrecognised
keyword is a parse error. A collector MUST NOT guess at an unrecognised
value, and MUST NOT skip a field it recognised but could not interpret.
The asymmetry is the point. An unknown field is something the sender knows about and this collector does not, and ignoring it loses only what was never understood. An unknown value in a known field is the sender saying something specific about this record, and proceeding without understanding it stores something other than what was sent.
3.29.3 Response statuses #
A client MUST treat a response whose status it does not recognise as
an error terminating the query, and MUST discard the "ok" messages it
has received for that query unless "end" or "watch" had already
arrived (§3.16).
A status is the control flow of the response stream, so there is no ignoring one: a client that skipped an unknown status would be waiting for a terminal message that had already been sent, or treating an incomplete result as complete. Failing is the only safe reading.
A collector MUST NOT introduce a new status for a condition that the four existing ones can express.
3.29.4 What may change without notice #
- New optional fields in a log record, a metric record or a query request.
- New fields in result records. A client MUST tolerate a key it does not recognise, and MUST NOT reject a record for carrying one.
- New query keywords, clauses and functions. A client sending one the collector does not know receives a parse error, which is the correct answer.
- Wording of any error string (§3.16).
- New event types, log origins and metric names. These are data, not interface; nothing enumerates the valid set of any of them.
3.29.5 What may not change #
- The meaning of an existing field, in either direction. A field is added or it is left alone.
- The type of an existing field.
- The four response statuses, or the rule that exactly one terminal message ends a query.
- The framing of §3.15, which has no version field and therefore no way to change compatibly.
- A required field becoming optional, or an optional one becoming required.
3.29.6 Limits are not the interface #
The declared bounds — the datagram ceilings above their portable floor (§3.6, §3.9), the query request ceiling and response target (§3.15), the concurrency and timeout bounds (§3.14, §3.16), the existence window and lookback limit (§3.26) — are configuration, and an administrator may change any of them within its stated range.
A collector MUST behave identically at any value in its supported range. A producer or client MUST NOT infer a bound from having exceeded one, or from not having exceeded one, and MUST NOT depend on the mainline defaults quoted in this chapter.
The 262144-byte log and metric datagram floor is different: it is part of the interface. A collector may raise its declared ceiling but MUST NOT lower it below that floor, and a portable producer does not discover or depend on the raised value (§3.6).
3.30 Conformance
Peios / Peios technical reference / PSPU / Observability Interfaces
A conforming implementation of any role MUST satisfy every requirement in this chapter. This section collects the obligations that are not tied to one message.
3.30.1 A collector #
Serve three separate channels. Two SOCK_DGRAM for ingestion, one
SOCK_STREAM for queries, each on its own socket, each protected by a
Security Descriptor established before it accepts anything (§3.3).
Never exert backpressure. No producer stalls because of a collector, under any load, in any failure state (§3.4).
Never react to input. No event, no log entry, no client-observable counter, in response to a malformed, unwanted or excessive submission (§3.4).
Authenticate ingestion. Admit only the service-manager broker on
the log socket; require a complete KACS token on every metric datagram
and authorize every metric name for EVENTD_PUBLISH (§3.3, §3.6,
§3.9).
Validate at the stated scope. Datagram, record, or field — as §3.8 and §3.12 set out, and no more broadly. In particular a malformed record MUST NOT cost the valid records batched with it, and a malformed optional field MUST NOT cost a log record.
Store what you were given. A log message byte-for-byte, an event payload unmodified, a timestamp uncorrected, a histogram's boundaries in the order sent (§3.8, §3.10, §3.5).
Preserve gaps. No interpolation, no backfill, no synthesised sample (§3.13).
Identify every query client from the connection, before executing anything, and refuse the query if you cannot (§3.14).
Order totally and deterministically. Every result, for a fixed set
of stored records, in one order — so that SKIP and TAKE mean
something (§3.21).
Use query-language semantics, not your storage engine's, for every comparison, ordering, grouping and equality test the language defines (§3.20, §3.21).
Enforce access before you compute, per concrete identifier, failing closed, and silently (§3.28).
Bound everything a client can consume: concurrent queries, streaming queries, message size, query time, distinct-stream values, cross-type lookback (§3.14, §3.15, §3.16, §3.26, §3.27).
Behave identically across your configured ranges (§3.29).
3.30.2 A producer #
Send well-formed records and accept that malformed ones vanish without notice (§3.8, §3.12).
Stay within the 262144-byte portable datagram ceiling, batched or not. Do not add discovery or depend on a collector's larger local setting (§3.6).
Choose a stable, conforming metric name. It must match the identifier grammar, name the producer distinguishably and use dots for hierarchy, because publication and read rules are written against it and queries select on it (§3.10).
Convey metric identity. Enable KACS_SO_PASS_TOKEN before sending
metrics and retain the sending socket so the captured-token and
authorization caches remain effective (§3.9).
Timestamp at production, not at submission (§3.7).
Bound your label cardinality, and keep histogram boundaries fixed for the life of a metric (§3.10, §3.13).
Never assume delivery. No acknowledgement exists, none is coming, and a record that mattered should have been an event (§3.4).
Never change a metric's type. Doing so ends the series silently and permanently (§3.10).
3.30.3 A client #
Tolerate unknown keys in result records, and unknown statuses as errors (§3.29).
Discard partial results. An error before "end" or "watch" means
every "ok" message for that query is void (§3.16).
Assume nothing about completeness. Results are silently filtered by access, counts count only what you may see, and an absent field is indistinguishable from a denied one (§3.28).
Interpret provenance narrowly. A log origin is broker-attested and a metric name is publisher-authorized; neither proves the message, value or labels are truthful (§3.28).
Do not parse error strings (§3.16).
Open one connection per query (§3.14).
3.30.4 What this chapter does not require of a collector #
A conforming collector need not accelerate anything, pre-compute
anything, shard anything, or retain anything for any particular period.
It need not honour INDEX beyond accepting it (§3.23). Its storage,
indexing, retention and query planning are entirely its own, and every
requirement above is stated about the answer rather than about how the
answer is reached.
Appendix 3.A Limits
Peios / Peios technical reference / PSPU / Observability Interfaces
Every bound this chapter requires a collector to enforce, with the value and adjustable range of the mainline collector. The mainline values are informative: a conforming collector chooses its own, and a producer or client MUST NOT depend on any of them (§3.29).
The mainline configuration key names are those of eventd, whose configuration is catalogued in the eventd TRMP §A.
3.A.1 Ingestion #
| Bound | Mainline value | Mainline range | Key | Section |
|---|---|---|---|---|
| Log datagram ceiling | 262144 B | 262144 – 1048576 | MaxLogDatagramBytes | §3.6 |
| Metric datagram ceiling | 262144 B | 262144 – 1048576 | MaxMetricDatagramBytes | §3.9 |
| Receive queue, either socket | ≤ 4 × the ceiling | — | — | §3.6 |
3.A.2 Queries #
| Bound | Mainline value | Mainline range | Key | Section |
|---|---|---|---|---|
| Query request ceiling | 65536 B | 1024 – 16777216 | MaxQueryRequestBytes | §3.15 |
| Query response target | 65536 B | 1024 – 16777216 | QueryResponseTargetBytes | §3.15 |
| Query timeout | 30000 ms | 1000 – 300000 | QueryTimeoutMs | §3.16 |
| Concurrent queries | 128 | 1 – 4096 | MaxConcurrentQueries | §3.14 |
| Concurrent streaming queries | 64 | 1 – 1024 | MaxStreamingQueries | §3.14 |
| Concurrent queries per caller | 16 | 1 – 4096 | MaxQueriesPerUser | §3.14 |
| Memory all running queries hold together | 268435456 B | 16777216 – 4294967295 | MaxQueryHeldBytes | §3.16 |
| Values per DISTINCT stream | 100000 | 1000 – 10000000 | MaxDistinctStreamValues | §3.27 |
3.A.3 Cross-type filtering #
| Bound | Mainline value | Mainline range | Key | Section |
|---|---|---|---|---|
Existence window W | 15000 ms | 1000 – 300000 | CrossTypeWindowMs | §3.26 |
| Maximum lookback | 604800 s | 3600 – 2592000 | CrossTypeMaxLookbackSeconds | §3.26 |
3.A.4 Fixed by this chapter #
These are not configuration and a collector MUST NOT vary them.
| Quantity | Value | Section |
|---|---|---|
| Timestamp domain | 0 – 9223372036854775807 ns | §3.5 |
| GUID field width | 16 bytes | §3.7, §3.11 |
| Message length prefix | 4 bytes, little-endian | §3.15 |
| Maximum response payload | 2^32 - 1 bytes | §3.15 |
| Transforms per query | at most 1 | §3.25 |
| Terminal aggregations per query | at most 1 | §3.25 |
| Queries per connection | exactly 1 | §3.14 |
The response target is not a ceiling. A complete record larger than the target travels alone, so the configurable ingestion ceilings do not need to be coupled to it (§3.15).
Appendix 3.B Query Language Reference
Peios / Peios technical reference / PSPU / Observability Interfaces
An index of the language, and of where each construct is valid. The normative definitions are in §3.18 to §3.27; nothing here adds a rule.
3.B.1 Shape #
EVENTS [type_pattern] [clauses…]
LOGS [FROM o[, o…]] [ERROR ONLY] [CONTAINING s] [clauses…]
METRIC name[label_selector] [transform] [aggregation] [clauses…]
3.B.2 Clause validity #
| Clause | EVENTS | LOGS | METRIC | Section |
|---|---|---|---|---|
SINCE / UNTIL | yes | yes | yes | §3.19 |
WHERE | yes | yes | yes | §3.20 |
WHERE METRIC | yes | yes | no | §3.26 |
WHERE EVENT … EXISTS | no | yes | yes | §3.26 |
WHERE LOG … EXISTS | yes | no | yes | §3.26 |
SORT | yes | yes | yes | §3.21 |
TAKE / SKIP | yes | yes | yes | §3.18 |
SELECT | non-aggregating only | non-aggregating only | no | §3.22 |
COUNT BY / TOP N BY | yes | yes | no | §3.23 |
DISTINCT | yes | yes | no | §3.23 |
GROUP + function | yes | yes | no | §3.23 |
STREAM | yes | yes | no | §3.27 |
ERROR ONLY / CONTAINING | no | yes | no | §3.24 |
INDEX | yes | no | no | §3.23 |
WHERE and SELECT are the only repeatable clauses (§3.18).
3.B.3 Combinations that are rejected #
| Combination | Rejected at | Section |
|---|---|---|
SELECT with COUNT BY, TOP N BY, DISTINCT or GROUP | parse | §3.22 |
SELECT in metric mode | parse | §3.22 |
STREAM with COUNT BY, TOP N BY or GROUP | parse | §3.27 |
STREAM with UNTIL | parse | §3.27 |
DISTINCT … STREAM with SORT, TAKE or SKIP | parse | §3.27 |
Window aggregation without SINCE | parse | §3.25 |
| Scalar and window aggregation together | parse | §3.25 |
| Two transforms | parse | §3.25 |
Cross-type filter without SINCE | parse | §3.26 |
= outside a label selector | parse | §3.20 |
== NULL or != NULL | parse | §3.19 |
| Ordering operator on a binary literal | parse | §3.20 |
| Ordering operator on a fixed field that cannot be ordered | parse or planning | §3.20 |
| Unknown log field name | parse | §3.24 |
| Effective range beyond the lookback limit | planning | §3.26 |
| Selected metric series spanning more than one type | execution | §3.25 |
RATE or DELTA on a gauge or histogram | execution | §3.25 |
| Percentile on a counter or gauge | execution | §3.25 |
| Histogram series with no percentile function | execution | §3.25 |
Unbracketed metric query with SINCE resolving to several series | execution | §3.25 |
| Cross-type metric selector resolving to several series | execution | §3.26 |
| Aggregation producing a non-finite value | execution | §3.23, §3.25 |
| A sort or aggregation needing more memory than the collector allows queries | execution | §3.16 |
"Parse" failures need no data. "Execution" failures depend on what the store holds, so the same query string may succeed on one system and fail on another.
3.B.4 Metric functions #
| Keyword | Stage | Valid on | Produces |
|---|---|---|---|
RATE | transform | counter | per-second change |
DELTA | transform | counter | absolute change |
P50 P95 P99 | transform | histogram | one value per sample |
AVG MIN MAX SUM | scalar aggregation | counter, gauge | one value |
AVG_OVER MIN_OVER MAX_OVER SUM_OVER | window aggregation | counter, gauge | one value per window |
Transforms feed aggregations; a query may have at most one of each (§3.25).
3.B.5 Operators #
== != > >= < <= STARTS_WITH ENDS_WITH CONTAINS HAS
IN NOT_IN IS NULL IS NOT NULL, combined with AND and OR
(§3.20).
CONTAINS is substring on a string; HAS is membership in an array.
There is no NOT and no =.
3.B.6 Literals #
| Kind | Form | Section |
|---|---|---|
| Identifier | [A-Za-z_][A-Za-z0-9_.-]* | §3.19 |
| String | "…" with \" \\ \n \r \t \uXXXX | §3.19 |
| Binary | x"0a1b…", even digit count | §3.19 |
| Integer | decimal or 0x… | §3.19 |
| Float | finite, with a fraction or exponent | §3.19 |
| Boolean | true, false | §3.19 |
| Null | NULL, in IS NULL only | §3.19 |
| Duration | <n>s <n>m <n>h <n>d, non-zero | §3.19 |
| Time | <duration> ago, <duration> hence, today, yesterday, YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS | §3.19 |
| GUID | 8-4-4-4-12, braced or not | §3.19 |
3.B.7 Fields #
| Mode | Fixed fields | Everything else |
|---|---|---|
| EVENTS | event.time event.sequence event.cpu event.type event.boot.guid emitter.class emitter.token.guid emitter.true-token.guid emitter.process.guid | a flattened payload path, or null |
| LOGS | timestamp origin is_error message boot_id job_id | a parse error |
| METRIC | timestamp boot_id name type value overflow | a label, or null |
3.B.8 Aliases #
emitter.class accepts userspace (0), kmes (1), kacs (2), lcs
(3). These are the only aliased values in the language (§3.23).
3.B.9 Default ordering #
| Mode | Without SORT |
|---|---|
| EVENTS | event.time descending |
| LOGS | timestamp descending |
| METRIC | timestamp ascending |
COUNT BY, TOP N BY, GROUP … COUNT | count descending |
GROUP … SUM, AVG, MIN, MAX | group keys ascending in the order named |
DISTINCT | by the distinct value |
All ties are broken to a total order (§3.21).
Appendix 3.C Prior Art
Peios / Peios technical reference / PSPU / Observability Interfaces
The three interfaces here are not novel, and each has a well-known counterpart whose shape informed it. What follows compares the contracts — this appendix is about wire shapes and the obligations they place on either side. The eventd TRMP §1.4 compares the systems.
3.C.1 Log ingestion #
The closest relative is journald's native socket: a Unix datagram socket, world-writable, accepting a self-describing record from any local process, with no acknowledgement and no notification of loss. The agreements are substantive — datagram rather than stream, silent drop under pressure, and a forwarder bridging programs that only know standard output. The identity model differs: this interface admits only the service-manager broker, which assigns the origin from the pipe it read rather than accepting a process's self-assertion.
The differences are three. The record here is MessagePack rather than a line-oriented key-value text format, because the collector already carries a MessagePack decoder for event payloads and a second parser would be a second thing to get wrong. Severity is a boolean rather than a syslog priority, because a forwarder can distinguish two file descriptors and inventing eight levels from two would be a guess presented as data (§3.7). And a batch is a first-class datagram shape rather than a stream of records, which is what lets a forwarder amortise the syscall without giving up the datagram's all-or-nothing property.
Classic syslog over /dev/log is the older relative, and the departure
from it is the same one journald made: a record with named fields rather
than a formatted line that every consumer re-parses with a regular
expression.
3.C.2 Metric ingestion #
The shape is StatsD's: push, datagram, fire-and-forget, no series registration, sender-named series. KACS sender identity and per-name publication authorization are deliberate additions. It is the opposite of Prometheus's, where the collector pulls from endpoints it has been configured to know about.
The choice follows from the loss model rather than from taste (§3.9). A pulling collector must reach every producer on a schedule, which makes it responsible for their availability; pushing keeps a slow or dead producer invisible except for the gap it leaves.
What is taken from the Prometheus data model rather than from StatsD is the identity of a series: a name plus a set of labels, with each distinct label combination a distinct series, and the cardinality warning that comes with it (§3.10). The histogram is Prometheus's cumulative-bucket form, including the property that the top bucket is an overflow whose contents are counted but not located.
Two things are deliberately absent. There is no text exposition format, because nothing scrapes. And there is no summary type — a producer that has already computed its own quantiles cannot submit them, because quantiles do not aggregate and a stored one could not be combined with another (§3.25).
3.C.3 The query interface #
The unusual choice here is having a query language at all.
journald exposes a cursor and a set of field matchers, and computation belongs to the client. The Windows Event Log exposes XPath over an XML representation. Prometheus exposes PromQL, a genuine language, but only for metrics. This interface puts one language over all three data types, with a shared clause vocabulary and per-type modes (§3.18).
The reason is access control. Filtering, grouping and aggregation must happen on the side that knows what the caller may see, because a count computed by a client is a count of what the client was given and a count computed by the collector can be a count of what the client is entitled to (§3.28). A cursor interface pushes the computation across the trust boundary and takes the enforcement point with it.
The framing — a length-prefixed MessagePack request, a sequence of
chunked result messages, one terminal message — is unremarkable and
deliberately so. What it does not have is more interesting: no version
field (§3.29), no error codes (§3.16), no multiplexing (§3.14), and no
cursor. A query is one connection, and paging is SKIP and TAKE over
a total order (§3.21) rather than an opaque token the collector must
keep state for.
3.C.4 Where these interfaces sit #
| Concern | Where it is specified |
|---|---|
| Event emission and the ring-buffer transport | PSPK |
| Event types and payload schemas | the emitting subsystem's own documentation |
| Tokens, SIDs and Security Descriptors | PCDS, and the Peios Kernel TRM |
| Forwarding a service's output | the peinit TRM |
| Storage, indexing, retention, query planning | the collector's own design; for the mainline one, the eventd TRMP |
4.1 Scope and Roles
Peios / Peios technical reference / PSPU / Service Control and Notification
This chapter defines the two interfaces a Peios service manager offers: the control channel, by which a program manages services, and the notification channel, by which a supervised service reports on itself.
Both are Unix-domain sockets between userspace parties, and both have a publicly implementable side. A monitoring tool, an orchestration agent, a shell utility, or a privileged action broker implements the client side of the control channel. Every supervised service that reports readiness, sends keepalives, or preserves file descriptors across a restart implements the producer side of the notification channel.
4.1.1 The roles #
The manager is the process that supervises services. It listens on both channels. On Peios this is peinit, running as PID 1, but nothing here depends on that beyond the manager being a single process holding both sockets.
A client connects to the control channel to issue commands and read answers. A client is any process; it holds no special relationship with the manager beyond the one its token establishes.
A service is a process the manager started, and speaks the notification channel about itself. A service does not connect to the control channel in that capacity — a program that does both is acting in two roles.
A job the manager started on a submitter's behalf (§7) speaks the notification channel exactly as a service does, and is observed and stopped from the control channel by the job commands of §4.14. The channel by which it is submitted is §7's.
Requirements are stated against the role, not the program.
4.1.2 What this chapter covers #
- the two channels, their addressing, and how each is reached
- message framing and encoding on both
- how a client's identity is established, and how a command is authorised
- the command set, the response shapes, and the error vocabulary
- what a command does to a service in each of its states
- how a service's notification is authenticated, and what a service may say
- the file-descriptor store
- the rules under which either channel may be extended
- the conformance requirements for each role
4.1.3 What this chapter does not cover #
- How the manager supervises anything. Dependency resolution, restart policy, timers, cgroups, the boot sequence and shutdown are the manager's own design. This chapter defines what a client can ask for and what it is told, not how the answer comes about.
- How service definitions are expressed. On Peios they are registry keys, administered like any other registry data. That is the service manager's own design.
- What a service state means. The vocabulary is fixed here (§4.B) because it appears on the wire; what causes a service to be in one of those states is not.
- Kernel interfaces. Establishing a peer's identity and evaluating an access decision are kernel operations, specified in PSPK and in the kernel's own reference manual.
- Submitting a job. The channel by which a process asks the manager to run a program under supervision, the identity such a job runs as, and the commands its submitter issues about it are §7. This chapter covers only what a control-channel client sees of such a job.
4.2 Terminology
Peios / Peios technical reference / PSPU / Service Control and Notification
Service. A named unit of execution the manager supervises. Service names are opaque to this chapter except for the character restriction in §4.8.
Job. One process execution. A service that has been restarted has had more than one job. A job submitted on the jobs channel (§7) has no service; it belongs to its submitter.
Operation. A requested state machine action on a service, with an identity and a lifecycle of its own. Lifecycle commands do not act directly; they create operations, and an operation is what a client observes and waits on.
Activation generation. A counter the manager increments each time a service begins starting. It distinguishes one incarnation of a service from the next.
Right. A named permission on a service or on the manager itself, represented as a bit in an access mask and evaluated against a Security Descriptor. §4.7.
Dependent-satisfying state. A service state in which the services that depend on the service may proceed. Which states these are is the manager's design; that a state is or is not one of them is observable through the state vocabulary.
Terminal state. For an operation, one of completed, failed,
cancelled, merged or aborted. An operation in a terminal state
does not change again.
Frame. One newline-terminated line on the control channel, carrying exactly one JSON object.
Datagram. One message on the notification channel, carrying zero or
more KEY=VALUE lines and optionally file descriptors.
4.3 The Two Channels
Peios / Peios technical reference / PSPU / Service Control and Notification
The two channels differ in almost every respect, and the differences are deliberate.
| Control | Notification | |
|---|---|---|
| Socket type | SOCK_STREAM | SOCK_DGRAM |
| Who connects | The client | Nobody; a service sends |
| Addressing | A fixed path | A path given to each service |
| Direction | Request and response | One-way |
| Framing | Newline-delimited JSON | KEY=VALUE lines |
| Identity | The peer's token, at connect | The sender's kernel-attested PID |
| Authorisation | An access check per command | Membership: is the sender this service? |
| Loss | None. A stream, or an error | Possible. A datagram may be dropped |
| Ordering | Guaranteed within a connection | Not guaranteed |
4.3.1 Why the notification channel is a datagram socket #
A service reporting on itself must not be able to block the manager, and must not block itself. A stream socket gives both parties a queue that fills, and a service writing into a full queue either blocks — hanging a service on the manager's scheduling — or gets an error it has to handle in the middle of doing something else.
A datagram socket has neither problem. A send either goes or is dropped, and the manager can drain at whatever rate it manages. The cost is that a notification can be lost, which is why nothing in §4.19 is a transaction: every field is either idempotent or a statement of current condition, and a service that needs a lost keepalive to have arrived sends another one.
4.3.2 Why the control channel is a stream socket #
A command has an answer, and a client waiting for one needs to know it did not arrive rather than assuming. It also needs framing: a request can be large, and a response certainly can.
4.3.3 Reaching either socket #
Both sockets are protected by the Security Descriptor on the socket's own inode, and a party that may not reach the socket is refused when it connects or sends, before any content is exchanged.
The manager MUST NOT rely on POSIX mode bits for this. On a Peios system
access to a filesystem object is routed through its Security Descriptor,
mode bits are not consulted, and a chmod on either socket has no
effect whatever.
The manager MUST ensure that each socket, and each directory containing one, carries a Security Descriptor that admits the parties intended to use it. A socket created where nothing inheritable applies acquires no descriptor, and an object with no descriptor is denied to every caller — so a manager that leaves this to chance produces a socket nobody can reach, including principals its own default policy grants access to.
4.4 The Control Channel
Peios / Peios technical reference / PSPU / Service Control and Notification
The manager MUST listen on a Unix SOCK_STREAM socket at a
well-known path. On Peios that path is:
/run/services/peinit/control.sock
The socket MUST exist for as long as the manager is serving, and the manager MUST unlink it when it stops.
The manager MUST create the listening socket and every accepted connection with close-on-exec set, so that no connection descriptor is inherited by a process the manager starts.
4.4.1 A connection #
A client connects, issues one or more commands, and closes. The manager MUST NOT require a client to issue any command before another, and MUST NOT hold state across connections: a connection carries an identity (§4.6) and nothing else.
Requests on one connection MUST be answered in the order they were received. The manager MAY read no further frames from a connection while a response on it is outstanding.
4.4.2 Limits #
The manager MUST enforce three limits, and MUST make their values discoverable to an administrator through the same configuration surface that sets them. The values a Peios service manager uses by default are in §4.A.
Concurrent connections. A connection accepted while the manager is already at its limit MUST be closed at the socket level, without a response. There is no error code for this condition: the manager has declined to enter the protocol at all, and a client MUST treat an immediate close with no response as a refusal rather than as a protocol error.
The limit MUST be applied to each caller, counted by the user SID of the peer's token, as well as to the connections in all: a connection accepted while its caller already holds its limit is closed the same way. The manager MAY exempt the machine's own principal (SYSTEM) from the per-caller limit. A channel that admits every authenticated principal and bounds only the total lets any one of them fill it and shut everyone else out, administrators included.
Request size. A request frame whose content exceeds the limit MUST
be answered with REQUEST_TOO_LARGE and the connection MUST then be
closed. The limit applies to the frame's content and MUST NOT count the
terminating newline, so a request of exactly the limit plus its newline
is within bounds.
Idle timeout. A connection with no request outstanding MAY be closed
once it has been idle for the configured period. The manager MUST NOT
treat a connection as idle while a request on it is outstanding — in
particular a connection blocked on a wait=true operation (§4.13) is
not idle, however long the operation runs, and MUST be held open until
the operation resolves. Such a connection is bounded by the operation's
own timeout, not by the idle timeout.
A connection closed for idleness MUST be closed without a response.
4.5 Framing and Encoding
Peios / Peios technical reference / PSPU / Service Control and Notification
4.5.1 Frames #
Every message in both directions is one frame: a single JSON object,
serialised compactly, followed by one 0x0A byte. This applies to
requests and to responses alike, and the manager MUST terminate every
response with a newline.
Framing is byte-oriented and is performed before any JSON is parsed. A
0x0A byte ends the frame wherever it appears, so a raw newline inside
what a sender intended as a JSON string does not produce one frame with
an embedded newline — it produces two malformed ones. (A raw 0x0A
inside a JSON string is not valid JSON in any case; the \n escape
sequence is unaffected and is the way to carry a newline in a value.)
The manager MUST NOT emit pretty-printed JSON, and MUST NOT emit more than one object per frame.
4.5.2 Encoding #
Frames are UTF-8. The manager MUST reject a frame that is not
well-formed UTF-8 with MALFORMED_REQUEST.
4.5.3 What is malformed #
The manager MUST answer with MALFORMED_REQUEST when a frame:
- is empty — a bare newline with no content;
- is not well-formed UTF-8;
- is not valid JSON;
- is valid JSON but not an object. An array, a string, a number,
true,falseandnullare all malformed requests.
4.5.4 Closing after an error #
The manager MUST distinguish two classes of failure, because they say different things about the connection.
A frame-level failure means the manager cannot trust the stream's
framing any more: it does not know where the next frame begins.
MALFORMED_REQUEST for an empty frame and REQUEST_TOO_LARGE are both
frame-level. The manager MUST send the error response, discard any
buffered input, and close the connection.
A command-level failure means the frame was well-formed and the command in it could not be carried out: unparseable JSON content, an unknown command, missing arguments, a denied access check, an unknown service. The manager MUST send the error response and MUST keep the connection open.
A client MUST NOT assume a connection survives an error response, and MUST be prepared for either.
4.5.5 Timestamps #
Every timestamp field the manager emits MUST be a UTC RFC 3339 string
with exactly nine fractional-second digits and the literal offset
marker Z:
"2026-06-01T12:34:56.123456789Z"
The manager MUST NOT emit a numeric offset in place of Z, and MUST NOT
vary the number of fractional digits.
These are wall-clock instants, presented for a reader. The manager MUST NOT derive elapsed-time decisions — timeouts, retries, ordering — from wall-clock differences, and a client MUST NOT assume that two timestamps in the same response were taken from a clock that did not move between them.
4.6 Peer Identity
Peios / Peios technical reference / PSPU / Service Control and Notification
The manager MUST establish the identity of every client from the kernel. There is no credential exchange in this protocol, and a client MUST NOT be able to assert who it is.
4.6.1 Obtaining the identity #
On accepting a connection, the manager MUST obtain the peer's token through the kernel's peer-identity facility for connected local sockets (the Peios Kernel TRM §3.5). The token is captured by the kernel when the client connects; nothing in this protocol carries it, and a client has no way to supply one.
The token reflects the identity the client was acting under when it connected. A client that is impersonating another principal is therefore captured as the principal it is impersonating, not as its own service identity — which is the intended behaviour: access decisions reflect the identity a client is actually acting under.
4.6.2 When it is captured #
The manager MUST capture the identity once, when the connection is accepted, and MUST use that identity for every command on the connection.
A client MUST NOT expect a change of identity mid-connection to affect authorisation. A client that needs to act under a different identity MUST open a new connection.
4.6.3 Failure #
If the manager cannot obtain the peer's identity, it MUST close the connection without a response. There is no error code, because the manager has no basis on which to decide whether this caller may be told anything at all.
A client MUST treat an immediate close with no response as a refusal. This is the same observable outcome as exceeding the connection limit (§4.4), and a client cannot distinguish the two — deliberately, since distinguishing them would tell an unauthenticated caller about the manager's state.
4.6.4 The manager's identity #
The kernel's facility is symmetric: a client that has connected MAY obtain the manager's identity through the same facility and verify it before sending a command — that the peer is the principal it expects the manager to run as, rather than something else that bound the path. A client MUST NOT treat the control socket's path as proof of the manager's identity; the path is a rendezvous, and the token is what the kernel attests.
4.6.5 The identity is not a UID #
The manager MUST NOT use the peer's UID or GID as an authorisation input. Identity on a Peios system is a token, and the token is what the kernel attests.
4.7 Authorising a Command
Peios / Peios technical reference / PSPU / Service Control and Notification
Every command is authorised against a Security Descriptor, using the peer's token. There is no command the manager performs without a check, and no principal exempt from one.
4.7.1 Rights #
Commands acting on a service are checked against that service's descriptor:
| Right | Bit | Grants |
|---|---|---|
SERVICE_QUERY_STATUS | 0x0001 | Query the service's state and detail. |
SERVICE_START | 0x0002 | Start the service. |
SERVICE_STOP | 0x0004 | Stop the service. |
SERVICE_INTERROGATE | 0x0008 | Reload the service. |
SERVICE_ALL_ACCESS | 0x000F | All four. |
Commands acting on a submitted job are checked against that job's own
descriptor, with the rights and generic mapping §7.8 defines:
JOB_QUERY (0x0001), JOB_STOP (0x0002), JOB_SIGNAL (0x0004).
Commands acting on the system are checked against the manager's own descriptor:
| Right | Bit | Grants |
|---|---|---|
SYSTEM_SHUTDOWN | 0x0001 | Initiate a shutdown. |
SYSTEM_RELOAD_CONFIG | 0x0002 | Re-read the configuration. |
SYSTEM_QUERY_STATUS | 0x0004 | Ask how the machine booted (boot). |
4.7.2 Generic mappings #
The manager MUST use these generic mappings when evaluating a descriptor, so that a descriptor written in generic terms means the same thing to every implementation.
For a service descriptor:
| Generic right | Maps to |
|---|---|
GENERIC_READ | SERVICE_QUERY_STATUS |
GENERIC_WRITE | SERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE |
GENERIC_EXECUTE | SERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE |
GENERIC_ALL | SERVICE_ALL_ACCESS |
For the manager's descriptor:
| Generic right | Maps to |
|---|---|
GENERIC_READ | SYSTEM_QUERY_STATUS |
GENERIC_WRITE | SYSTEM_RELOAD_CONFIG |
GENERIC_EXECUTE | SYSTEM_SHUTDOWN |
GENERIC_ALL | SYSTEM_SHUTDOWN | SYSTEM_RELOAD_CONFIG | SYSTEM_QUERY_STATUS |
GENERIC_READ maps to the one right on the manager's descriptor that
changes nothing, as it maps to SERVICE_QUERY_STATUS on a service.
4.7.3 Per command #
| Command | Right required |
|---|---|
start | SERVICE_START |
stop | SERVICE_STOP |
restart | SERVICE_START and SERVICE_STOP |
reload | SERVICE_INTERROGATE |
reset | SERVICE_STOP |
status | SERVICE_QUERY_STATUS |
list | Evaluated per service; see below |
operation-status | SERVICE_QUERY_STATUS on the operation's target |
job-status | JOB_QUERY on the job |
job-list | Evaluated per job; see below |
job-stop | JOB_STOP on the job |
shutdown | SYSTEM_SHUTDOWN |
reload-config | SYSTEM_RELOAD_CONFIG |
boot | SYSTEM_QUERY_STATUS |
boot is checked like any other command, although what it reports is
not a secret: a descriptor decides who may ask, and §4.15 asks that the
default one let every authenticated principal.
reset requires SERVICE_STOP because clearing a terminal state is the
tail of stopping something rather than the head of starting it.
4.7.4 The sequence #
- If the manager is shutting down, apply §4.15's restriction. The
shutdown restriction is evaluated before the access check, so a
caller who would have been denied is told the command is invalid for
the current state. A client MUST NOT infer anything about its own
rights from an
INVALID_STATEreceived during shutdown. - Resolve the target. A command naming no service the manager knows of
MUST be answered
UNKNOWN_SERVICE. The manager MUST NOT synthesise a descriptor for a service that does not exist. - Evaluate the access check with the peer's token, the target's descriptor, the appropriate generic mapping, and the required right, naming the target in the check's audit context (PGSS §6.7).
- On denial, answer
ACCESS_DENIED. The attempt is recorded by the access check itself, under the target descriptor's SACL, with the caller, the target and the rights requested and granted; the manager MUST NOT write an access-denied event of its own (PGSS §6.7). A descriptor the manager supplies by default MUST carry a SACL that audits every refusal, so that a denial is not silent unless a descriptor someone wrote says so. - On grant, proceed.
A status, and each job view on this channel, also tells the caller
which of these rights it holds on the target, as granted (§4.14). The
manager finds them with this same check, asking for MAXIMUM_ALLOWED
in step 3 instead of the command's right. That check is a question
asked on the caller's behalf, not a command: a right it does not grant
is left out of granted, and it is not a denial for step 4.
4.7.5 Filtering rather than denying #
list MUST return only the services the caller may query, and MUST
omit the rest rather than denying the command. A caller with no
query rights on anything receives an empty list and a successful
response.
The response does not say whether services were omitted. What the
filtering protects is each service's state, which is what
SERVICE_QUERY_STATUS grants, and not whether the service exists: a
command naming a service is answered UNKNOWN_SERVICE or by its access
check according to whether it exists (step 2 above), and §4.10 does not
hide existence either. A client MAY therefore learn from elsewhere which
services are defined and show the ones list left out as ones whose
state the caller may not see.
job-list MUST be filtered the same way, by JOB_QUERY on each job,
and MUST NOT reveal that jobs were omitted.
4.7.6 Not revealing what a caller may not see #
A service's existence is not protected (above). Where a command names an operation the caller may not query, the manager MUST NOT let the answer distinguish "this does not exist" from "you may not see this".
For operation-status this means the authorisation check MUST be
evaluated before the operation's existence is reported: a caller lacking
SERVICE_QUERY_STATUS on an operation's target MUST receive
ACCESS_DENIED whether or not the identifier names a real operation,
and MUST NOT receive UNKNOWN_OPERATION for one that exists.
Where the caller's rights cannot be established because the target
cannot be resolved, UNKNOWN_OPERATION is the correct answer.
For job-status and job-stop the rule is §7.10's, not this one: a
job identifier is unguessable, so a caller that names one was told it,
and the manager answers UNKNOWN_JOB for an identifier that names
nothing and ACCESS_DENIED for one that names a job the caller may not
touch.
4.8 Requests
Peios / Peios technical reference / PSPU / Service Control and Notification
A request is one JSON object.
4.8.1 Fields #
| Field | Type | Required | Meaning |
|---|---|---|---|
command | string | always | The command to run. §4.11, §4.14, §4.15 |
service | string | for service commands | The target service's name. |
wait | bool | no | Whether to block until the operation resolves. §4.13 |
type | string | for shutdown | poweroff, reboot or halt. |
operation_id | string | for operation-status | The operation to report on. |
job_id | string | for job-status, job-stop | The job. |
submitter | string | no | For job-list: only jobs with this submitter SID. |
identity | string | no | For job-list: only jobs whose identity is this SID. |
logon_session | integer | no | For job-list: only jobs whose identity is in this logon session. |
state | string | no | For job-list: only jobs in this state (§7.B). |
command MUST be present and MUST be a string naming a command the
manager implements. A request whose command is absent, is not a
string, or names no known command MUST be answered INVALID_COMMAND.
service MUST be present and a string for start, stop, restart,
reload, reset and status. Its absence, or a non-string value, MUST
be answered INVALID_ARGUMENTS.
wait MUST be a boolean when present. A non-boolean MUST be answered
INVALID_ARGUMENTS. Its default is per command (§4.13).
type MUST be present and MUST be exactly one of the three values for
shutdown. Anything else MUST be answered INVALID_ARGUMENTS.
operation_id MUST be present and a string for operation-status. A
value that is not a well-formed identifier MUST be answered
INVALID_ARGUMENTS.
job_id MUST be present and a string for job-status and job-stop,
under the same rule. The four job-list filters are each optional;
one that is present with a malformed value — a SID that does not parse,
a state outside §7.B — MUST be answered INVALID_ARGUMENTS rather than
matching nothing, and filters that are all present MUST all hold.
4.8.2 Fields that do not apply #
A field the command does not use MUST be ignored, not rejected. A
service on a list, or a wait on a status, is accepted and has no
effect.
This is what makes the request shape extensible: a client written against a later revision may send a field an earlier manager does not know, and the earlier manager ignores it. §4.21.
4.8.3 Service names #
A service name is 1 to 128 bytes drawn from [A-Za-z0-9._-]. The
manager MUST NOT accept a name outside that set, and a client MUST NOT
send one.
The restriction exists because service names are used as path
components and as configuration key names by managers that store their
definitions in a hierarchy. / and : are excluded specifically:
the first because it is a separator wherever the name is used as a path
component, and the second because it is conventionally reserved for a
manager's own synthetic naming.
4.9 Responses
Peios / Peios technical reference / PSPU / Service Control and Notification
Every response carries a status field, which MUST be exactly "ok" or
"error". What else it carries depends on which of four shapes it is.
4.9.1 The acknowledgement shape #
Returned by a lifecycle command that created, merged into, queued, cancelled, cleared or executed an operation.
| Field | Type | Meaning |
|---|---|---|
operation_id | string | The operation to observe. |
service | string | The target. |
state | string | The service's state when the response was formed. §4.B |
cause | string or null | Why the service last transitioned. §4.B |
warnings | array of strings | Human-readable warnings. Often empty. |
mode | string | For a reload only. §4.13 |
warnings here is an array of strings. The status response uses
the same field name for an array of objects (§4.14); a client MUST
distinguish them by which command it sent, not by inspecting the array.
4.9.2 The status shape #
Returned by status, and also by a lifecycle command that had no effect
— see §4.12. §4.14 gives it in full, including granted, the rights
the caller holds on the service, which it carries either way.
4.9.3 The system shape #
Returned by shutdown:
Nothing else. A shutdown has no operation to observe and no service to
report on. reload-config and boot have shapes of their own (§4.15).
4.9.4 The error shape #
code MUST be one of the values in §4.10. message is human-readable
and is not normative: a client MUST NOT parse it, match on it, or branch
on its content. Two managers answering the same request with the same
code MAY word the message differently.
4.9.5 Nullability #
A field that does not apply to the current state MUST be present and
null rather than omitted, except where this chapter says otherwise.
A client MUST accept null for any field this chapter marks nullable,
and MUST NOT treat a null as an error.
The one exception is mode, which appears only on a reload response.
The manager's structured events are not responses and do not follow
this rule: their payloads follow PGSS §6.5, under which a value that
does not apply is absent, never null.
4.10 Errors
Peios / Peios technical reference / PSPU / Service Control and Notification
The code field of an error response MUST be one of these values. The
manager MUST NOT emit any other code, and a client MUST treat a code it
does not recognise as an unrecoverable error for that request (§4.21).
| Code | Meaning | Closes? |
|---|---|---|
MALFORMED_REQUEST | The frame is not a single well-formed JSON object. §4.5 | On an empty frame |
REQUEST_TOO_LARGE | The request exceeds the configured maximum. §4.4 | Yes |
INVALID_COMMAND | command is absent, not a string, or names no known command. | No |
INVALID_ARGUMENTS | A field the command requires is absent or malformed. | No |
UNKNOWN_SERVICE | The named service has no definition the manager can act on. | No |
UNKNOWN_OPERATION | The operation identifier names nothing the manager holds — it never existed, or its retention has elapsed. §4.14 | No |
UNKNOWN_JOB | The job identifier names nothing the manager holds — it never existed, or its retention has elapsed. §7.7 | No |
ACCESS_DENIED | The access check denied the requested right. §4.7 | No |
INVALID_STATE | The command is not valid for the service's current state (§4.12), or the manager is shutting down (§4.15). | No |
OPERATION_TIMEOUT | A wait=true request's operation did not reach a terminal state in time. §4.13 | No |
INTERNAL_ERROR | The manager failed while executing the command. | No |
4.10.1 Distinctions a client can rely on #
UNKNOWN_SERVICE versus ACCESS_DENIED. A caller that may not
query a service still receives UNKNOWN_SERVICE for a name that does
not exist and ACCESS_DENIED for one that does but which it may not
touch. This chapter does not attempt to hide the existence of services:
the list filtering (§4.7) keeps a service's state from a caller that
may not query it, not the fact that the service exists.
INVALID_STATE versus ACCESS_DENIED during shutdown. During
shutdown the state restriction is evaluated first, so a caller who would
have been denied receives INVALID_STATE instead. A client MUST NOT
infer that it holds a right from receiving INVALID_STATE.
OPERATION_TIMEOUT does not cancel anything. It reports that the
client's wait ended, not that the operation did. The operation continues
and can still be observed with operation-status.
4.10.2 Codes are not extensible without a version #
The manager MUST NOT introduce a new code without the version negotiation in §4.21. A client written against this revision will not recognise one, and the only safe thing it can do with an unrecognised code is fail the request — so a new code silently converts a handled condition into an unhandled one.
4.11 Lifecycle Commands
Peios / Peios technical reference / PSPU / Service Control and Notification
Five commands move a service through its state machine. None of them acts directly: each creates, merges into, queues or cancels an operation, and the operation is what actually happens.
| Command | Effect | Default wait |
|---|---|---|
start | Start the service. | true |
stop | Stop the service, escalating if it does not exit. | true |
restart | Stop then start, under one operation. | true |
reload | Tell the service to re-read its configuration. | false |
reset | Clear a terminal state, returning the service to inactive. | false |
reload defaults to not waiting because a reload's outcome is often
advisory, and a client usually wants the identifier rather than the
block. reset is synchronous and completes before the response is sent,
so waiting on it would mean nothing.
4.11.1 Operations #
The manager MUST return an operation identifier from any lifecycle command that created, merged into, queued, cancelled, cleared or executed an operation. The client uses it to poll (§4.14) or to correlate.
The manager MUST NOT invent an operation solely so that it has an identifier to return. Where a command had no effect, or the service was already in the state asked for, the manager MUST return the status shape instead of an acknowledgement (§4.12).
4.11.2 Merging #
Where an operation of the same type is already in flight for the same service, the manager MUST merge the new request into it and MUST return the existing operation's identifier.
A merged caller therefore receives an identifier that may be older than
its own request, whose requested_at precedes the moment it sent the
command. This is correct — that is when the work being waited on began —
and a client MUST NOT treat an identifier older than its request as an
error.
The manager MUST NOT tell the caller that a merge occurred. A merge is not a distinguishable outcome, and a client cannot do anything with the knowledge.
4.11.3 What completion means #
| Command | The operation completes when |
|---|---|
start | The service reaches a dependent-satisfying state, or a state indicating its start-time conditions did not apply. |
stop | The service is no longer running. |
restart | The service reaches its normal successful start target after the restart. |
reload | The reload resolves, whatever its mode. |
reset | Immediately. |
4.11.4 Timeouts #
Every queued or executing operation has a maximum lifetime, derived from the target service's own configured timeouts; a start the service manager is holding on an undecided dependency has none and MUST stay Pending until the dependency is decided.
The lifetime is measured from the operation's creation, including any time it spent queued. From the caller's point of view they have been waiting since they sent the command, and an operation that sat behind another for longer than its lifetime MUST fail rather than begin.
An operation whose lifetime expires while it is still queued MUST fail, and MUST fail its waiters. Expiry of the operation object MUST NOT by itself authorise the manager to act on the service — a stop operation that timed out while waiting its turn does not license signalling the service ahead of that turn.
4.12 Command Outcomes by State
Peios / Peios technical reference / PSPU / Service Control and Notification
A command sent to a service in an unexpected state MUST receive a defined answer. The manager MUST NOT silently do nothing.
| inactive | starting | active | reloading | stopping | completed | backoff | failed | abandoned | skipped | |
|---|---|---|---|---|---|---|---|---|---|---|
start | act | merge | already | already | queue | act | defer | act | invalid | act |
stop | noop | cancel + act | act | act | merge | clear | cancel | noop | invalid | noop |
restart | act | queue | act | act | queue | act | act | act | invalid | act |
reload | invalid | invalid | act | merge | invalid | invalid | invalid | invalid | invalid | invalid |
reset | noop | invalid | invalid | invalid | invalid | invalid | invalid | clear | clear | clear |
status | ok | ok | ok | ok | ok | ok | ok | ok | ok | ok |
4.12.1 The outcomes #
act — create an operation and execute it. The manager returns the acknowledgement shape.
merge — an operation of this type is in flight. The command merges into it (§4.11) and the caller receives that operation's identifier.
queue — the operation is created and left pending; it executes once the operation ahead of it completes. The caller receives the new operation's identifier.
defer — an automatic restart is already pending for this service.
The manager MUST create a pending start operation, or merge into a
deferred one that already exists, and MUST NOT execute it until the
existing delay has elapsed. A start MUST NOT shorten a pending
restart's delay.
already — the service is in the state the command would take it to and no operation of this type is in flight. The manager MUST return the status shape, not an error and not an acknowledgement.
noop — the command has no effect. The manager MUST return the status shape.
clear — the service returns to inactive. This is a synchronous outcome; the manager returns an acknowledgement.
cancel — abort or cancel the operation in flight, then proceed.
invalid — the command is not valid for this state. The manager MUST
answer INVALID_STATE.
ok — status is answered from any state.
4.12.2 The backoff column #
A service in backoff is down with an automatic restart pending, and
the four lifecycle commands mean different things there:
startdefers, as above, and honours the remaining delay.stopcancels both the pending restart and any deferred start, and the service becomes inactive.restartcancels the automatic restart and performs a caller-initiated one.reloadandresetare invalid: there is no process to reload and no terminal state to clear.
4.12.3 The abandoned column #
Every lifecycle command except reset is invalid on an abandoned
service. reset clears it. Nothing else is meaningful while processes
the manager could not terminate are still present.
4.12.4 A service being withdrawn #
A manager MAY keep supervising a service whose definition has been
removed while an instance of it is still running. In that condition the
manager MUST answer start, restart and reload with
UNKNOWN_SERVICE, MUST accept stop, and MUST report the condition in
the status shape (§4.14). This holds whatever the service's state.
4.13 Wait Semantics
Peios / Peios technical reference / PSPU / Service Control and Notification
wait decides whether a lifecycle command's response is sent
immediately or held until the operation resolves.
With wait: false, the manager MUST respond as soon as it has accepted
the operation, with the acknowledgement shape and the service's state at
that moment.
With wait: true, the manager MUST hold the connection open and respond
when the operation reaches a terminal state, with the same shape and the
service's state, cause and warnings observed at that time.
A connection blocked on a wait is not idle (§4.4). The manager MUST NOT close it for idleness however long the operation runs.
4.13.1 When a wait ends #
| Ending | Response |
|---|---|
| The operation reaches a terminal state | The acknowledgement shape. |
| The operation's lifetime expires | OPERATION_TIMEOUT. |
| The operation is no longer held by the manager | UNKNOWN_OPERATION. |
OPERATION_TIMEOUT ends the client's wait, not the operation. The
operation continues, and the client MAY still observe it with
operation-status using the identifier it never received — which it
does not have. A client that needs to survive a timeout SHOULD issue the
command with wait: false, keep the identifier, and poll.
4.13.2 Reload mode #
A response to a reload command MUST carry a mode field saying how
the reload resolved:
| Value | Meaning |
|---|---|
confirmed | The service acknowledged the reload by signalling readiness. The reload demonstrably happened. |
advisory | The manager issued the reload and the service did not acknowledge it. The reload probably happened; nothing confirms it. |
failed | The reload did not happen. An external reload command exited non-zero or timed out. |
mode MUST be present on every response to a reload, including one
sent with wait: false — in which case it MUST be advisory, since
nothing has been observed yet.
A client MUST treat these three as an exhaustive set and MUST NOT expect a fourth. A manager MUST NOT introduce one without §4.21.
The distinction between confirmed and advisory is the whole value of
the field: a service that implements the reload handshake (§4.19) can be
known to have reloaded, and one that does not cannot. failed does
not mean the service stopped — a failed reload leaves a running service
running.
4.14 Query and Job Commands
Peios / Peios technical reference / PSPU / Service Control and Notification
Three commands read service state and change nothing. Three more observe and stop submitted jobs (§7), and are here because a client of this channel sees jobs alongside services.
4.14.1 status #
Returns everything the manager knows about one service.
| Field | Type | Meaning |
|---|---|---|
display_name | string or null | The definition's human-readable name. |
description | string or null | The definition's human-readable description. |
state | string | §4.B. |
cause | string or null | Why the service last transitioned. §4.B. |
status_text | string or null | The most recent status string the service sent (§4.19). |
progress | object or null | The most recent progress the service sent (§4.19), in the form §7.7 gives. |
current_job | object or null | The current main job, or null if none. |
current_operation | object or null | The current operation, or null if none. |
health | string or null | healthy, unhealthy, unknown, or null when the service has no health check configured. |
uptime_seconds | integer or null | Whole seconds since the current job started. Null when nothing is running. |
definition_removed | bool | True while the service's definition has been withdrawn and an instance is still draining (§4.12). |
warnings | array of objects | Conditions worth an operator's attention. |
timers | array of objects | The service's calendar timer triggers, as the manager has them armed. Empty when it has none. |
granted | array of strings | The service rights the caller holds on this service. Never null. |
current_job carries id, type (§4.B), pid, started_at and
identity. pid and started_at are independently nullable.
identity is the identity string the manager resolved for the
execution, which is not necessarily what the resulting token contains.
current_operation carries id, type and source (§4.B).
The manager MUST clear status_text and progress to null at the
start of every activation generation. A status string or a progress
figure from a previous incarnation MUST NOT survive a restart and be
reported as though it described the current process.
4.14.1.1 What the caller may do #
granted lists the service rights of §4.7 that the caller holds on the
service, each by its name lowercased and without SERVICE_:
query_status, start, stop, interrogate, in that order, and only
those held. It describes the caller who asked, not the service; two
callers asking about the same service may be told different things.
The manager MUST compute it with the access check it runs for a command
(§4.7) — the caller's token, the service's descriptor, the service
generic mapping — asking for MAXIMUM_ALLOWED rather than for one
right, and MUST report only the service rights among what that check
grants. A client can therefore offer only the commands that will be
allowed: while the caller's token and the service's descriptor stay as
they were, a command whose required rights (§4.7) all appear in
granted passes its access check, and one needing a right that is
absent fails it. Whether the command will then do anything is a
separate question, which the service's state answers (§4.12), and
during a shutdown §4.15's restriction comes first.
This check is a question, not a command. A right the caller lacks is
simply absent from granted, and the manager MUST NOT record the check
as a denial.
A status returned by a lifecycle command that had no effect (§4.12)
carries granted for the caller of that command.
4.14.1.2 Status warnings #
warnings in the status shape is an array of objects, not strings:
| Field | Type | Meaning |
|---|---|---|
path | string | What the warning is about. |
type | string | The kind of warning. §4.B. |
detected_at | string | When the manager noticed. §4.5. |
A client MUST accept a type it does not recognise and MUST NOT discard
the warning, since a warning it cannot classify is still one an operator
should see.
4.14.1.3 Status timers #
timers has one object for each calendar timer trigger of a service
that is not disabled (for peinit, the peinit TRM §9), ordered by
schedule:
| Field | Type | Meaning |
|---|---|---|
schedule | string | The calendar expression, as the trigger gives it. |
scheduled_at | string or null | The schedule's next occurrence. §4.5. |
fires_at | string or null | When the timer will fire: that occurrence, delayed by the jitter drawn for it. Equal to scheduled_at without jitter. §4.5. |
last_fired_at | string or null | When it last fired. Null when it has not fired since the manager started and no earlier firing is recorded. §4.5. |
not_armed | string or null | Why the manager has not armed it, when it has not: its schedule does not parse, or has no occurrence within the manager's search horizon. Null when armed. |
An armed timer has scheduled_at and fires_at, and null
not_armed. A timer not armed has only schedule and not_armed.
These are the manager's own figures, not a calculation a client could repeat. The jitter is drawn at random each time the timer is armed, and only the manager knows which schedules it refused, so a client MUST NOT compute a next firing from the definition and present it as when the service will run.
last_fired_at is the firing, not the run: it is when the manager
acted on the timer (the peinit TRM §9.2), whatever came of that. For a
persistent timer that has not fired since the manager started, it is
the timestamp the manager recorded before (the peinit TRM §9.3).
4.14.2 list #
Returns every service the caller may query, with a compact summary.
Exactly seven fields per entry: the name, the definition's nullable
display_name and description, the state, cause and health of the
status shape, and next_timer_at, the soonest fires_at among the
service's armed timers, or null when it has none. Services the caller
may not query are omitted (§4.7).
A service whose definition has been withdrawn is listed, and the list
entry does not say so. A client that needs to know MUST issue a
status.
4.14.3 operation-status #
Returns one operation by identifier.
| Field | Meaning | Present when |
|---|---|---|
id | The operation's identifier. | Always. |
type | §4.B. | Always. |
service | The target. | Always. |
source | Why the manager created it. §4.B. | Always. |
state | §4.B. | Always. |
result | The resulting service state. | completed. |
error | Why it did not complete. | failed, cancelled, aborted. |
merged_into | The surviving operation's identifier. | merged. |
requested_at | When it was created. | Always. |
started_at | When it began executing. | Once running. |
completed_at | When it reached a terminal state. | Once terminal. |
Fields that do not apply to the current state MUST be null.
error carries a reason for all three non-success terminal states, not
only for failed. A client MUST NOT read a non-null error as meaning
the operation failed — it MUST read state for that. Cancellation and
abortion have reasons worth reporting, and a separate field for each
would give a client three places to look for one fact.
4.14.4 job-status #
Returns one submitted job by identifier, as the job view of §7.7.
The view is §7.7's, with one field added on this channel: what a submitter sees of its job on the jobs channel is what a client sees of it here, and the client is also told what it may do to the job.
4.14.4.1 What the caller may do to a job #
On this channel every job view — in the response to job-status,
job-list and job-stop — carries one field beyond §7.7's:
| Field | Type | Meaning |
|---|---|---|
granted | array of strings | The job rights the caller holds on this job. Never null. |
It lists the job rights of §7.8 the caller holds, each by its name
lowercased and without JOB_: query, stop, signal, in that
order, and only those held. The manager MUST compute it as it computes
a status's granted, with the access check it runs for a job command,
against the job's own descriptor and the job generic mapping (§7.8),
and the same rules follow: it is a question rather than a command, a
right the caller lacks is absent rather than denied, and a command whose
required right is listed passes its access check.
For a job-stop that waits, the manager MAY compute granted when it
accepts the command rather than when it answers. A job's descriptor is
fixed at submission (§7.8), so the two could differ only if the
caller's token had changed in between.
The jobs channel's view does not carry granted. A submitter is
answered there under §7.8's own rules.
4.14.5 job-list #
Returns every submitted job the caller may query, as job views.
Each entry is the full job view (§7.7), with granted (above). Jobs
the caller may not query are omitted (§4.7). The four filters of §4.8 — submitter,
identity, logon_session, state — narrow the result before it is
filtered by right, and the manager MUST NOT let the response reveal
whether the filters or the rights removed an entry.
Terminal jobs still within their retention period are listed; a client
that wants only live ones filters by state.
4.14.6 job-stop #
Stops one submitted job, with the semantics of the jobs channel's
stop (§7.8): the termination signal, the job's stop_timeout, then
the kill.
wait defaults to true, and the response is the job view, with
granted, when the job is terminal; with wait: false it is the view
as soon as the stop has been initiated. A job-stop on a terminal job returns the view
unchanged, with "status": "ok".
This is the one command on this channel that acts on a job, and it
creates no operation: a job has no state machine to contend for (§7.6).
There is no job-signal here; signalling is the submitter's business,
on its own channel.
4.14.7 Retention #
The manager MUST hold an operation record for at least a grace period after it reaches a terminal state, so that a client polling for the result can retrieve it. The value a Peios service manager uses is in §4.A.
An identifier that never existed, and one whose record has been dropped
after its grace period, MUST both be answered UNKNOWN_OPERATION. A
client MUST NOT distinguish them, and MUST treat UNKNOWN_OPERATION
after a successful acknowledgement as meaning the result is no longer
available rather than that the operation never ran.
Job records are retained under §7.7's rule, and UNKNOWN_JOB carries
the same two meanings.
4.15 System Commands
Peios / Peios technical reference / PSPU / Service Control and Notification
Three commands are about the manager rather than a service. Two act on
it; the third, boot, asks how the machine booted.
4.15.1 shutdown #
type MUST be one of:
| Value | Meaning |
|---|---|
poweroff | Stop everything and remove power. |
reboot | Stop everything and restart the machine. |
halt | Stop everything and halt, leaving the machine powered. |
The response is {"status": "ok"} and nothing else. There is no
operation to observe: a shutdown is a mode the manager enters, not an
action on a service, and by the time it has finished there is nobody
left to tell.
A client MUST NOT expect the connection to survive. The manager MAY close it at any point after the response.
4.15.2 reload-config #
Re-reads the configuration and rebuilds whatever the manager derives from it.
| Field | Type | Meaning |
|---|---|---|
added | array of strings | Services that did not exist before. |
updated | array of strings | Services whose definition changed. |
restored | array of strings | Services whose withdrawal was reversed. |
marked_removed | array of strings | Services whose definition is gone but which are still running. |
discarded | array of strings | Services removed outright. |
warnings | array of strings | Human-readable warnings about the new configuration. |
Every member of summary MUST be present, even when empty. A client
MUST accept a member of summary it does not recognise, and MUST ignore
it (§4.21).
4.15.2.1 It is atomic #
The manager MUST validate the new configuration in full before adopting
any of it, and MUST adopt it only if validation succeeds. If validation
fails, the manager MUST leave the previous configuration in force and
MUST answer INVALID_STATE, reporting what was wrong.
A partially applied configuration is worse than the one already running: the running one at least booted.
4.15.2.2 It does not live-update #
The manager MUST NOT reconfigure a running service. A changed definition takes effect the next time that service starts.
4.15.3 boot #
Reports how the current boot went: which mode the machine booted in and why, how close it is to recovery, and whether this boot has counted as a successful one yet. It takes no arguments and changes nothing.
A boot mode is how much of the configuration a boot starts. In
full mode the manager starts everything configured to start at boot.
In safe mode it starts a reduced set: the services the boot cannot do
without, which this chapter calls critical services, and those
marked as wanted in Safe mode. In recovery mode it starts nothing and
offers a maintenance shell instead. Which services are critical, and
what each mode starts, is the manager's design.
A boot is confirmed once it has shown it works: every critical service has held a dependent-satisfying state (§4.2) for a grace period without a break. The manager keeps a boot attempt count, the number of boots in a row that started and were never confirmed, and enters recovery when it reaches a threshold rather than boot into the same failure again. Confirming a boot puts the count back to 0.
| Field | Type | Meaning |
|---|---|---|
mode | string | The mode the machine booted in, after any downgrade. §4.B |
reason | string | Why it is in that mode. §4.B |
downgrade | array of strings | What forced a downgrade, one human-readable finding each. Empty unless reason is safe_mode_downgrade. |
attempts | integer | The boot attempt count as the manager checked it against the threshold at the start of this boot. |
max_attempts | integer | The threshold. 0 means the check is off and the count never sends the machine to recovery. |
confirmed | bool | This boot has been confirmed, and the count has been put back to 0. |
grace_seconds | integer | The grace period, in seconds. |
waiting_on | array of strings | The critical services not yet holding a dependent-satisfying state. Empty once the grace period has ended. |
confirms_at | string or null | When the boot will be confirmed if every critical service keeps holding, in RFC 3339 UTC. Null while waiting_on is not empty, and once the grace period has ended. |
confirm_error | string or null | Why the count could not be put back to 0 when the grace period ended. Human-readable. |
Every member of boot MUST be present. A client MUST accept a member of
boot it does not recognise, and MUST ignore it (§4.21).
attempts counts the boots before this one; this boot is not among
them. A machine reaches recovery when a boot begins with attempts at
or above max_attempts, so max_attempts - attempts is how many more
unconfirmed boots the machine has before recovery.
confirmed MUST be true only once the count has actually been put back
to 0. If the grace period ended but the manager could not write the
count, confirmed is false and confirm_error says why: the next boot
will count this one as a failed attempt, whatever its services did, and
a client MUST NOT report the boot as confirmed.
A confirmed boot stays confirmed. A critical service that fails after
confirmation is a failure of that service, not of the boot, and does not
make confirmed false again.
A manager in recovery mode does not serve the control channel, so a
client SHOULD NOT expect to read recovery from boot; the value
exists so that a manager which does serve it there has a word for it.
boot is checked against the manager's descriptor for
SYSTEM_QUERY_STATUS (§4.7). How a machine booted is not a secret, and
a manager's default descriptor SHOULD grant this right to every
authenticated principal.
4.15.4 During shutdown #
Once the manager is shutting down, it MUST reject every command except
status, list, operation-status, boot, job-status, job-list
and job-stop with INVALID_STATE.
The first six are permitted because they change nothing and because a
client watching a shutdown proceed has a legitimate reason to keep
looking; job-stop because a job the shutdown will reach eventually
may be one a client has reason to end now. Everything else — including
a second shutdown — is refused:
the manager has committed to a course of action and a command that
would alter it arrives too late to be honoured consistently.
As §4.7 says, this restriction is evaluated before the access check, so
a caller who would have been denied receives INVALID_STATE instead.
4.16 The Notification Channel
Peios / Peios technical reference / PSPU / Service Control and Notification
A service reports on itself over a Unix SOCK_DGRAM socket the manager
binds and holds for the lifetime of the system.
4.16.1 Addressing #
The manager MUST make the socket's path available to each service it
starts, in the NOTIFY_SOCKET environment variable, set in the service
process's environment before exec.
The path is not part of this contract, and a service MUST NOT hardcode one. The manager MAY bind one socket for all services or one per service; a service cannot tell and MUST NOT depend on either.
The manager MUST set NOTIFY_SOCKET unconditionally, for every service
it starts, whatever readiness protocol that service uses. A service uses
this channel for keepalives, status, timeout extension and the
descriptor store as well as for readiness, and a manager that set the
variable only for services expected to signal readiness would make the
rest unreachable.
The manager MUST NOT allow NOTIFY_SOCKET to be overridden by any
configurable environment layer. A service that could override it would
silently disable its own supervision.
4.16.2 Direction and delivery #
The channel is one-way. The manager does not reply, and a service MUST NOT wait for one.
Delivery is not guaranteed. A datagram MAY be dropped, by the kernel under load or by the manager. Every field in §4.19 is therefore either idempotent or a statement of a current condition, and a service that needs an effect to have taken hold sends the field again rather than waiting for an acknowledgement that does not exist.
The manager MUST NOT let this channel exert backpressure on a service. A service MUST NOT be able to block by sending, and the manager MUST NOT require a service to slow down.
4.16.3 Bounds #
The manager MUST accept a datagram of at least the size in §4.A, and MUST accept at least the number of file descriptors in §4.A in one datagram's control message.
The manager MUST detect a datagram that exceeded either bound and MUST reject the whole datagram (§4.17). It MUST NOT process a truncated datagram: a truncation can leave a tail that parses as a complete, valid line, which would apply a field the sender did not send.
A service MUST NOT send a datagram exceeding either bound.
4.17 Datagram Framing
Peios / Peios technical reference / PSPU / Service Control and Notification
A datagram carries zero or more lines, separated by 0x0A. Each line is
KEY=VALUE.
READY=1
STATUS=Listening on port 8096
A trailing newline on the last line is permitted and is not a line of
its own. A trailing 0x0D on any line MUST be stripped before the line
is interpreted, so a sender that emits CRLF is understood.
The datagram MUST be well-formed UTF-8.
4.17.1 Applying a datagram #
The manager MUST parse every line before applying any of them, and MUST apply every line of a datagram it accepts, in order.
If any line is malformed, the manager MUST reject the entire datagram and apply nothing from it. Any file descriptors it carried MUST be closed.
Partial application is the failure this rule exists to prevent. A
datagram saying RELOADING=1 and something unintelligible has an
ambiguous meaning, and applying the half that parsed picks one reading
of it silently.
4.17.2 What is malformed #
A line is malformed when it is non-empty and:
- it contains no
=; or - its key is empty.
An empty line is not malformed. It is skipped.
A datagram is malformed when it is not well-formed UTF-8, or when it exceeded a bound in §4.16.
4.17.3 Three ways a line can fail to take effect #
These are distinct and a service author needs the distinction:
| Situation | Effect on the datagram | Effect on the line |
|---|---|---|
| A malformed line | Rejected entirely | — |
| An unrecognised key | Applied normally | Ignored |
| A recognised key with an unexpected value | Applied normally | Ignored |
The second is what makes the field set extensible (§4.21): a service built against a later revision may send a field an older manager does not know, and the older manager applies the rest.
The third is the one that surprises. READY=0 is not a malformed line
and does not reject the datagram; READY expects the value 1 and
anything else is silently ignored. A service MUST NOT send a recognised
key with a value the field does not define, and MUST NOT expect to be
told when it does. §4.19 gives each field's accepted values.
4.17.4 Rejection is recorded, not answered #
The manager MUST record a rejected datagram, with at least the sender's identity and the reason, and MUST attribute it to a service where the sender could be identified.
It MUST NOT reply. There is nothing to reply on.
4.18 Sender Authentication
Peios / Peios technical reference / PSPU / Service Control and Notification
A datagram on this channel claims to be a supervised process — a service, or a submitted job (§7) — talking about itself. The manager MUST establish that it is.
4.18.1 The requirements #
The manager MUST enable SO_PASSCRED on the socket, and MUST reject any
datagram arriving without a kernel-attested credentials control message.
It MUST then establish all of the following, and MUST drop the datagram if any fails:
- The sender is a service's current main job, or a submitted job. The manager MUST match the attested PID against the main jobs and the submitted jobs it is supervising. A hook process, a health check, or a child a service or a job forked MUST NOT be able to notify on its behalf.
- That job has exec'd and is running. A job still in setup has not become the service yet.
- That job has a kernel handle on the process — a pidfd, or an equivalent that refers to one specific process rather than to a number.
- The handle still refers to the attested PID. The manager MUST verify the PID against the handle rather than trusting the PID alone.
- For a service's job, its activation generation is the service's current one. A submitted job has no generation and no replacement; the step does not apply to it.
4.18.2 Why steps 3 and 4 exist #
A PID identifies a process only until that process exits. Between a service writing a datagram and the manager reading it, the service can die and its PID be recycled onto something else — and PID matching alone would then attribute the unrelated process's message to the service, or attribute the service's message to whatever now holds the number.
A handle obtained atomically at fork does not have that property. Verifying the attested PID against the handle is what turns a probable match into a certain one.
4.18.3 Why step 5 exists #
A datagram sent by an incarnation of a service that has since been
restarted MUST NOT be applied to its replacement. Without the generation
check, a READY=1 written by a process moments before it crashed could
mark the process that replaced it ready — declaring a service healthy on
the strength of a message from the one that just failed.
Readiness is per activation generation, and so is everything else on this channel.
4.18.4 What the manager MUST NOT use #
The manager MUST NOT use the sender's UID or GID as an authorisation input, and MUST NOT accept any identity a service asserts in the datagram's content.
Identity on this channel is which supervised process this is, and only the kernel can attest that. A service does not have a name here that it gets to state, and a job does not have an identifier.
4.18.5 What a job may send #
A submitted job may send every field in §4.19. Those that address a
service transition it is not in — RELOADING, EXTEND_TIMEOUT_USEC
outside a readiness wait, the descriptor-store fields, which a job has
no store for — MUST be applied as §4.19 says for the case where they do
not apply: ignored, or closed and recorded. READY=1 from a
readiness: none job is ignored. A job's STATUS and PROGRESS are
retained and exposed in its job view (§7.7).
4.19 Notification Fields
Peios / Peios technical reference / PSPU / Service Control and Notification
Every field a service may send. A manager MUST implement all of them. A service MUST NOT send a recognised key with a value the field does not define (§4.17).
4.19.1 Lifecycle #
| Field | Value | Meaning |
|---|---|---|
READY | 1 | Startup is complete and the service is serving. |
RELOADING | 1 | Configuration reload has begun. |
STOPPING | 1 | Graceful shutdown has begun. |
LEVEL | free text | A currently true readiness condition. Repeated fields publish the complete set; an empty-only set retracts it. |
READY=1 is what a service using notification readiness sends when
it is genuinely able to serve, not when its process exists. Anything
depending on the service starts on the strength of it, so a service that
signals early declares its dependents' assumptions true before they are.
RELOADING=1 opens a reload. The manager waits a bounded period
after issuing a reload for this field; a service that sends it MUST
follow with READY=1 when the reload is complete, and the pair is what
lets the manager report the reload confirmed rather than advisory
(§4.13). A service that never sends either still reloads — it just
cannot be observed to have done so.
STOPPING=1 tells the manager the service is already shutting down.
A manager that receives it MUST NOT send a further termination signal to
that service. It MUST NOT extend or reset the stop timeout: the service
still has to exit within it, and a service needing longer sends
EXTEND_TIMEOUT_USEC.
LEVEL names a condition the service currently satisfies, in the
service's own vocabulary. A service MAY repeat this field within one
datagram to publish several conditions. For example, a routed netd sends
LEVEL=link, LEVEL=addressed and LEVEL=routed as three lines in one
datagram.
When a datagram contains at least one LEVEL field, the manager MUST
atomically replace the sender's retained set with the distinct nonempty
values from all its LEVEL fields. Field order and duplicates MUST NOT
change the resulting set. An empty value adds no member; a datagram with
only empty LEVEL values MUST clear the set. A datagram without LEVEL
MUST leave the retained set unchanged. The entire replacement MUST be
visible before releasing dependents, including when the datagram also
contains READY=1.
The manager MUST discard the set whenever the service leaves a state that satisfies dependents: these are claims made by a running process.
A level-conditioned dependency (Requires = ["netd:routed"]) MUST NOT
be satisfied unless the target is in a dependent-satisfying state and
the retained set contains the named level. Names are opaque,
case-sensitive and per-publisher; commas have no special meaning. The
manager MUST NOT assume an ordering or implication between them. A
publisher that knows several conditions hold MUST publish each one that
it promises to expose to dependents.
The value SHOULD be a single token of at most 64 printable ASCII characters. A value outside that shape is not an error, but a dependency condition cannot name it, so nothing can ever wait on it.
4.19.2 Health #
| Field | Value | Meaning |
|---|---|---|
WATCHDOG | 1 | A keepalive. |
WATCHDOG_USEC | unsigned integer | Change the expected keepalive interval, in microseconds. |
EXTEND_TIMEOUT_USEC | unsigned integer | Extend the current transition's deadline, in microseconds. |
WATCHDOG_USEC with a value above zero sets the interval and MUST
re-arm the timer from the moment the message arrives, rather than
letting the new interval apply only from the next keepalive. A value of
zero MUST disable the watchdog.
The value MUST NOT persist across a restart. A restarted service gets the interval its definition specifies.
EXTEND_TIMEOUT_USEC sets the current transition's deadline to
expire that many microseconds from the message's arrival. It
replaces the deadline rather than adding to it, and MAY be sent
repeatedly.
Because it replaces, a value smaller than the time remaining shortens the deadline, and zero expires it immediately. A service MUST NOT send a value expecting it to be treated as a floor.
The manager MUST cap the extended deadline at four times the base timeout of the phase being extended, and MUST clamp rather than reject a value beyond the cap. During a system shutdown the manager MUST additionally cap it at the time remaining in the shutdown, and where both apply the stricter MUST win.
A message arriving while the service is not in a transition MUST be ignored. There is no deadline to extend.
4.19.3 Reporting #
| Field | Value | Meaning |
|---|---|---|
STATUS | free text | A human-readable statement of what the service is doing. |
PROGRESS | see below | How far the service has got. |
PROGRESS_UNIT | bytes, items or percent | What PROGRESS counts. |
ERRNO | free text | An errno-style error number. |
EXIT_STATUS | free text | An exit status, informationally. |
All five MUST be authenticated like any other field and MUST be recorded by the manager as structured events (PGSS §6), which, like every event, are not written while the emission policy has their type switched off (PGSS §6.9). They MUST NOT be forwarded to a log sink as though they were the service's output — they are the service speaking to the manager.
STATUS, PROGRESS and PROGRESS_UNIT MUST additionally be retained
and exposed — as status_text and progress in the status shape
(§4.14), and in a submitted job's view (§7.7). ERRNO and
EXIT_STATUS MUST NOT be retained.
4.19.3.1 Progress #
PROGRESS takes one of three forms, each of two unsigned decimal
integers at most, separated by /:
| Form | Meaning | A viewer shows |
|---|---|---|
N | Counting, with no end. | A rising count. |
N/ | Counting towards an end that exists but is not yet known. | A rising count, and expects a bound to arrive. |
N/T | N of T. | A bar. |
T MUST be at least 1, and N MUST NOT exceed T when T is given.
A value outside these forms, or violating either rule, is an
unexpected value under §4.17: the line is ignored and the datagram
is otherwise applied. A manager MUST NOT clamp or repair a progress
value.
The manager retains the most recent accepted value and exposes it as a
progress object: current is N; total is T or null; bounded
is true for the second and third forms and false for the first; unit
is the most recent accepted PROGRESS_UNIT or null. A PROGRESS_UNIT
outside its three values is an unexpected value and is ignored. Each
field is replaced only by a datagram that carries it: a STATUS alone
leaves progress as it was.
T MAY change from one datagram to the next — a job that rescans has
learned something — and N MAY fall. A viewer renders the latest
value; it MUST NOT infer motion from the difference between two.
A service that sends progress with no end in sight MUST send the
first form and not a guessed T. A service that has not started
counting sends nothing: absence means no progress concept, and a
viewer shows an indeterminate indicator.
The manager MUST bound how often a change of progress becomes an event on its event stream, so that a sender updating a thousand times a second cannot produce a thousand events; the retained value is the latest regardless. The bound a Peios service manager uses is §4.A.
A service MUST NOT include a newline or carriage return in a STATUS
value: it would frame as two lines, the second of which is almost
certainly malformed.
4.19.4 The descriptor store #
| Field | Value | Meaning |
|---|---|---|
FDSTORE | 1 | Store the descriptors attached to this datagram. |
FDNAME | free text | The name to store or remove them under. |
FDSTOREREMOVE | 1 | Remove the descriptors stored under FDNAME. |
FDPOLL | 0 | Do not monitor the stored descriptors for error conditions. |
§4.20.
4.19.5 Fields that are not supported #
| Field | Why |
|---|---|
MAINPID | A manager supervises the process it forked, through a kernel handle obtained at fork. There is no mechanism for redirecting supervision to another process, and there is deliberately none: a service that could nominate its own supervision target could nominate anything. |
BUSERROR | Peios has no D-Bus. |
Neither is rejected distinctly. Both are simply unrecognised keys and are ignored like any other (§4.17). A service MUST NOT rely on being told that it sent one.
4.20 The Descriptor Store
Peios / Peios technical reference / PSPU / Service Control and Notification
A service may hand file descriptors to the manager and get them back after a restart it did not choose. This is what lets a stateful daemon — one holding a listening socket, say — restart without dropping what it already had.
The manager MUST support a per-service maximum, which MAY be zero. Zero disables the store for that service, and a service MUST NOT assume a store exists.
4.20.1 Storing #
On an authenticated datagram carrying FDSTORE=1 with descriptors
attached, the manager MUST:
- If the store is disabled for this service, close the descriptors and record the rejection.
- If the store already holds its maximum, close the descriptors and record the rejection. It MUST NOT evict an existing entry — a full store is full, and silently discarding something the service is relying on to survive a restart would be worse than refusing the new one.
- Store them under the value of
FDNAMEif present and non-empty, and under the namestoredotherwise. - Note
FDPOLL=0if present.
A datagram MAY carry several descriptors. Each becomes its own entry under the one name, and each is independently subject to the maximum — so a datagram carrying more than will fit has some stored and the rest closed.
Several entries MAY share a name.
FDPOLL=0 asks the manager not to monitor the descriptors for error
conditions. A manager MAY monitor stored descriptors and remove ones
that have become invalid; a manager that does not MUST still accept the
field.
4.20.2 Removing #
FDSTOREREMOVE=1 with FDNAME MUST remove every entry of that name and
close its descriptors. A name matching nothing is a no-op and MUST NOT
be an error.
FDSTOREREMOVE=1 without FDNAME MUST be treated as a malformed
line, rejecting the whole datagram (§4.17). A remove with no name has no
defined meaning, and the alternative readings — remove everything,
remove the default name, do nothing — are far enough apart that guessing
between them silently is worse than refusing.
4.20.3 Returning them #
When the service starts again, the manager MUST pass the stored descriptors to the new process:
- Placed consecutively, starting at descriptor 3, with close-on-exec cleared.
LISTEN_FDSset to the number of descriptors passed.LISTEN_FDNAMESset to the names, colon-separated, in the same order as the descriptor numbers.LISTEN_PIDset to the new process's own PID.- The store cleared.
LISTEN_PID is what lets a service verify that the variables are
addressed to it rather than inherited from an ancestor. A conforming
client checks it against its own PID before trusting LISTEN_FDS, and
treats a mismatch as meaning no descriptors were passed — so a manager
that omits it hands descriptors to a service that will not take them.
All four variables MUST be absent when no descriptors are passed, and
the manager MUST NOT allow any of them to be set by a configurable
environment layer. A LISTEN_FDS reaching a service that was passed
nothing points its descriptor-adopting code at whatever happens to be at
descriptor 3.
Descriptors are returned to the service's main process only. A hook or a probe MUST NOT receive them.
The store MUST be cleared once the descriptors have been passed. The manager MUST NOT clear it when a start attempt fails before that point — the descriptors are still the service's, and the next attempt should get them.
A returned listening socket conveys, to every client that connects, the
identity of the process that called listen() on it — the previous
instance of the service. A service that receives listening sockets from
the store SHOULD restamp them with its own identity before accepting
(the Peios Kernel TRM §3.5), so that clients verifying the peer see the
instance actually serving them.
4.20.4 When the store is emptied #
The manager MUST clear the store, closing its descriptors, when:
- the service is stopped deliberately — by a client, or as part of a system shutdown; or
- the service's definition is withdrawn and its entry is finally discarded.
The manager MUST NOT clear it on a restart the service did not ask for — a crash, or a restart policy acting on one. That case is the entire purpose of the mechanism: the descriptors survive exactly the restart the service could not prepare for.
4.21 Extension
Peios / Peios technical reference / PSPU / Service Control and Notification
Neither channel carries a version number. Both are extended by the rules below, which are what allow a client and a manager built against different revisions to interoperate.
4.21.1 What may be added #
A request field. A manager MUST ignore a request field it does not recognise (§4.8). A client MAY therefore send a field a manager may not know, and MUST NOT depend on the field having had an effect.
A response field. A client MUST ignore a response field it does not
recognise, and MUST NOT treat its presence as an error. This includes an
unrecognised member of summary in a reload-config response, and an
unrecognised key in current_job or current_operation.
A notification field. A manager MUST ignore an unrecognised key (§4.17). A service MAY therefore send a field a manager may not know.
A type value in a status warning. A client MUST accept a warning
whose type it does not recognise and MUST NOT discard it. An
unclassifiable warning is still a warning.
A right in granted. A client MUST ignore a right name it does not
recognise in a status's or a job view's granted (§4.14). A right it
does not know of is one it offers no command for.
A command, together with the error codes and enumerated values that appear only in responses to it. A client is exposed to a command only by sending it, so a client that does not know a command is never exposed to anything it introduces. A manager MAY therefore add a command without a version, provided that nothing it introduces can reach a client through any existing command's response.
4.21.2 What may not be added without a version #
Anything a client must recognise in order to behave correctly cannot be added compatibly, because an older client's only options are to fail or to misbehave.
A manager MUST NOT, without a negotiated version:
- introduce an error code outside §4.10 on an existing command;
- introduce a service state, transition cause, operation state, operation type, operation source or job type outside §4.B, or a job state or cause outside §7.B, where an existing command could return it;
- introduce a reload mode outside the three in §4.13;
- change what an existing command does;
- change the shape of an existing response, including changing a field's type or making a non-nullable field nullable.
A client encountering one of these has no correct behaviour available.
Faced with an unknown state it cannot decide whether the service is
running; faced with an unknown error code it cannot decide whether to
retry.
4.21.3 What a client must do with the unknown #
A client MUST treat an unrecognised enumerated value in a field it
depends on as an error for that request, and MUST NOT map it onto the
nearest value it does know. Guessing that an unfamiliar state is
probably like active is how a monitoring tool reports a broken system
as healthy.
A client MUST treat an unrecognised error code as unrecoverable for that request. It MUST NOT retry, since it cannot know whether the condition is transient.
4.21.4 Versioning, when it comes #
A future revision introducing an incompatible change MUST do so through an explicit negotiation, in which a client states what it understands and the manager answers within that. Until such a mechanism exists, this chapter's contract is fixed and the rules above are the whole of the supported way for it to grow.
4.22 Conformance
Peios / Peios technical reference / PSPU / Service Control and Notification
4.22.1 A conforming manager #
The channels. Listens on a Unix stream socket at a well-known path
and on a Unix datagram socket whose path it gives each service in
NOTIFY_SOCKET. Ensures both sockets, and the directories containing
them, carry a Security Descriptor admitting the parties intended to
reach them, and relies on no POSIX mode bits (§4.3, §4.4, §4.16).
Framing. Emits exactly one compact JSON object per newline-terminated
frame. Answers a malformed frame with MALFORMED_REQUEST and an
oversized one with REQUEST_TOO_LARGE, closing the connection after a
frame-level failure and holding it open after a command-level one
(§4.5).
Identity. Obtains every client's identity from the kernel once, at accept, and uses no UID, GID or asserted identity (§4.6).
Authorisation. Checks every command against the appropriate Security
Descriptor with the mappings in §4.7, naming the target in each
check's audit context and giving every default descriptor a SACL that
audits every refusal, writes no access-denied event of its own, filters
list rather than denying it, and does not let operation-status
distinguish an operation the caller may not see from one that does not
exist. Reports a status's and a job view's granted from that same
check asking for MAXIMUM_ALLOWED, and does not treat it as a denial
(§4.14).
Commands. Implements all fourteen, with the outcomes in §4.12 for
every command-and-state pair, the response shapes in §4.9, §4.14 and
§4.15, and only the error codes in §4.10. Reports a boot as confirmed
only once its attempt count has been put back to 0 (§4.15). Filters job-list by
JOB_QUERY as it filters list, and answers job-status and
job-stop against the job's own descriptor (§4.7, §4.14).
Operations. Returns an identifier from every lifecycle command that produced one and none where it did not; merges same-type requests and returns the surviving identifier; measures every operation's lifetime from its creation including queue time; and holds a terminal record for at least the grace period (§4.11, §4.14).
Waiting. Honours the per-command wait default, holds a waiting
connection open past the idle timeout, and carries a mode on every
reload response (§4.13).
Notification. Authenticates every datagram through all five steps of
§4.18, including verifying the attested PID against a kernel handle and
checking the activation generation for a service's job. Applies all
lines of an accepted datagram and none of a rejected one. Rejects a
truncated datagram rather than processing it. Implements every field in
§4.19, retaining STATUS, PROGRESS and PROGRESS_UNIT and bounding
how often a progress change becomes an event.
The descriptor store. Closes rather than keeps what it refuses;
returns descriptors from 3 upward with LISTEN_FDS, LISTEN_FDNAMES
and LISTEN_PID set; clears the store on a deliberate stop and keeps it
across a restart the service did not ask for (§4.20).
Extension. Ignores unrecognised request and notification fields, and introduces nothing from §4.21's closed list without a negotiated version.
4.22.2 A conforming client #
Sends one compact JSON object per newline-terminated frame. Treats an
immediate close with no response as a refusal. Does not parse message.
Accepts null for every nullable field, and unrecognised fields
everywhere it is told to. Treats an unrecognised enumerated value or
error code as an error for that request rather than guessing. Reads
state rather than the presence of error to decide whether an
operation succeeded. Does not infer its own rights from an
INVALID_STATE received during shutdown. Opens a new connection to act
under a different identity.
4.22.3 A conforming service #
Reads NOTIFY_SOCKET from its environment and hardcodes no path. Sends
READY=1 when it can genuinely serve, not when its process exists.
Sends no recognised key with an undefined value, no newline inside a
STATUS value, and no PROGRESS outside its three forms. Sends no datagram exceeding the bounds in §4.A. Expects
no reply, and no acknowledgement that a field was applied. Treats
EXTEND_TIMEOUT_USEC as replacing a deadline rather than adding to one.
Checks LISTEN_PID against its own PID before adopting any descriptor.
4.22.4 What conformance is not #
A system that offers neither channel is still Peios (PSPU §1.2). These are contracts for the components that do offer them, not a bar the platform requires anything to clear.
Appendix 4.A Limits and Defaults
Peios / Peios technical reference / PSPU / Service Control and Notification
The values a Peios service manager uses. A manager MAY use different ones; where a value is configurable, it MUST be discoverable to an administrator through the same surface that sets it.
4.A.1 Control channel #
| Bound | Value | Configurable | Defined in |
|---|---|---|---|
| Socket path | /run/services/peinit/control.sock | No | §4.4 |
| Concurrent connections per caller | 16; SYSTEM exempt | Yes | §4.4 |
| Concurrent connections in all | 256 | Yes | §4.4 |
| Request size | 65536 bytes, excluding the terminating newline | Yes | §4.4 |
| Idle timeout | 30 seconds | Yes | §4.4 |
| Listen backlog | 32 | No | §4.4 |
4.A.2 Operations #
| Bound | Value | Defined in |
|---|---|---|
| Terminal record retention | 60 seconds | §4.14 |
| Operation lifetime | The target service's own start or stop timeout | §4.11 |
4.A.3 Notification channel #
| Bound | Value | Defined in |
|---|---|---|
| Maximum datagram | 65536 bytes | §4.16 |
| Descriptors per datagram | 64 | §4.16 |
| First returned descriptor | 3 | §4.20 |
| Descriptor store maximum | Per service; 0 disables | §4.20 |
| Timeout extension cap | 4 × the phase's base timeout | §4.19 |
| Progress event rate | At most one per sender per second | §4.19 |
4.A.4 Composing the two channels #
A STATUS value a service sends on the notification channel is
returned as status_text on the control channel. The notification
datagram bound is 65536 bytes and the control response is not bounded by
the request limit, so a status string that fits in a datagram is always
returnable.
The bounds are stated at their values here rather than left to each implementation because a producer has no other way to learn them. Lowering either without telling anyone breaks every service that was sizing to the old one, and the failure — a truncated datagram, or a connection closed mid-request — does not name its cause.
Appendix 4.B Wire Vocabulary
Peios / Peios technical reference / PSPU / Service Control and Notification
Every enumerated value that appears on the control channel. All are lower snake case. Except for status warning types and granted rights, these sets are closed: a manager MUST NOT emit a value outside them without the version negotiation in §4.21, and a client MUST treat one it does not recognise as an error for that request rather than mapping it onto a value it knows. The open sets state their handling rules below.
These sets are the control channel's. The same words appear in the
manager's structured events in kebab-case, as every event value is
(PGSS §6.5): explicit_start on the channel is explicit-start in an
event.
4.B.1 Response status #
ok, error
4.B.2 Service state #
| Value | Process? | Satisfies dependents? |
|---|---|---|
inactive | No | No |
starting | Maybe | No |
active | Yes | Yes |
reloading | Yes | Yes |
stopping | Briefly | No |
completed | No | Yes |
backoff | No | No |
failed | No | No |
abandoned | Yes, unkillably | No |
skipped | No | Yes |
Exactly four states satisfy dependents: active, reloading,
completed and skipped. A client deciding whether something depending
on this service could be running MUST use that set and no other.
4.B.3 Transition cause #
explicit_start, dependency_start, restart_policy,
binds_to_recovery, timer, explicit_stop, explicit_reload,
explicit_reset, conflict_eviction, binds_to_propagation,
shutdown_wave, process_crash, clean_exit, clean_exit_restart,
readiness_timeout, watchdog_timeout, health_check_failure,
pre_hook_failure, parent_setup_failure, pre_exec_failure,
dependency_failure, restart_budget_exhausted, cycle_detected,
validation_error, assertion_error, condition_skipped,
tty_unavailable, process_unkillable, internal_error
A cause may also be null, for a service that has not transitioned.
4.B.4 Service health #
healthy, unhealthy, unknown
health is null when the service has no health check configured,
which is distinct from unknown — the latter means one is configured
and has not produced a result yet.
4.B.5 Job type #
service_main, pre_exec_hook, post_exec_hook, reload_hook,
health_check, submitted
Only service_main appears in current_job. Only submitted appears
in a job view (§7.7); a job's state and cause vocabularies are §7.B's.
4.B.6 Operation type #
start, stop, restart, reload, reset
4.B.7 Operation state #
| Value | Terminal? | Meaning |
|---|---|---|
pending | No | Queued, not yet executing. |
running | No | Executing. |
completed | Yes | Reached its goal. |
failed | Yes | Did not reach its goal, or expired while queued. |
merged | Yes | Merged into another operation. |
cancelled | Yes | Terminated while pending. Never executed. |
aborted | Yes | Terminated while running. |
4.B.8 Operation source #
admin, boot, shutdown, dependency_propagation, restart_policy,
timer, binds_to_recovery, binds_to_propagation,
conflict_resolution, on_failure, tty_release
admin is the only source a client's own command produces. The rest
describe operations the manager created for its own reasons, and a
client observing one has learned something about what the manager is
doing rather than about anything it asked for.
4.B.9 Reload mode #
confirmed, advisory, failed
4.B.10 Status warning type #
service_tree, health, hooks, helper
These name what part of a service's process containment could not be
reclaimed, service_tree being the whole of it and therefore the most
serious. A client MUST accept a value outside this set and MUST NOT
discard the warning (§4.21).
4.B.11 Granted right #
In a status's granted: query_status, start, stop, interrogate
In a job view's granted, on this channel: query, stop, signal
Each names a right of §4.7 or §7.8, lowercased and without its
SERVICE_ or JOB_ prefix, and they appear in the order given here
(§4.14). A client MUST ignore a name outside these sets rather than
treat it as an error: an unrecognised right is one it has no command
for, and the rights it does know are still correctly reported.
4.B.12 Shutdown type #
poweroff, reboot, halt
Request-only; the manager does not echo it.
4.B.13 Boot mode #
full, safe, recovery
The modes §4.15 defines. Only boot returns one, and a manager in
recovery does not serve this channel, so in practice a client reads
full or safe.
4.B.14 Boot mode reason #
| Value | Meaning |
|---|---|
normal | A full boot, which is what a boot is unless something says otherwise. |
requested | The boot was asked for in this mode, on the kernel command line or its equivalent. |
safe_mode_downgrade | A full boot that the manager downgraded to safe because it found a fault it could not start the full configuration past, such as a critical service in a dependency cycle. downgrade lists each finding. |
5.1 Scope and Roles
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
This chapter specifies the peipkg package format and the peipkg repository protocol: the artifact by which compiled software is distributed to a Peios system, and the static-HTTP protocol by which a system discovers, trusts, and fetches those artifacts.
A package is the binary distribution primitive of Peios — the unit of build, distribution, and trust. It is deliberately narrow: it defines how binaries reach a system, not how they are integrated into services, roles, or features. Higher-level artifacts reference packages; a package knows nothing of them.
5.1.1 Roles #
Three roles speak this specification. A requirement is stated against the role, not the program; one program may serve more than one.
| Role | Obligation |
|---|---|
| Producer | Builds package files. Everything a .peipkg contains is a producer obligation. |
| Repository | Publishes a descriptor, two indexes, and package files over static HTTP, and signs the metadata. |
| Consumer | Fetches, verifies, and installs packages. Every validation and rejection rule binds the consumer. |
A repository operator is usually also a producer, but need not be: a repository may publish packages built elsewhere, and the format's signatures survive the journey.
5.1.2 In scope #
- The on-wire package file: container, internal layout, manifest schema, payload layout, per-file integrity
- Package identity: names, versions, version comparison, architectures
- How a package expresses its relationships to other packages, and what it means for one to satisfy another
- Package signing: algorithm, envelope, verification
- The repository protocol: descriptor, active and archive indexes, URL conventions, freshness and rollback protection
- Establishing and maintaining trust in a repository
- The rules under which the format may be extended
5.1.3 Out of scope #
- How a consumer decides what to install. Given several candidates that all satisfy a dependency, which one it picks, in what order it applies a plan, and how it recovers from an interrupted one are the consumer's own design.
- How a consumer stores its state. The installed-package database, its transaction journal, and its cache format are private.
- How a producer builds a package. Recipes, build farms, and source trees are producer mechanics; only their output is specified here.
- Roles, role features, core features, and applets. These are separate subsystems that reference packages.
- Integration metadata attached to packages — service definitions, registry seeds, reconciller manifests. These belong to the artifacts that compose packages, not to packages.
- Security descriptor semantics. A package carries security descriptor bytes; what they mean is specified with the kernel's access-control subsystem.
5.1.4 Relationship to other chapters #
Nothing in this chapter is a conformance requirement on a Peios system: a system that ships software some other way is still Peios (§1.1). What this chapter guarantees is that the format and the protocol are written down and will not move.
5.2 Terminology
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
-
Package — a binary distribution artifact: one or more files, metadata describing its identity and relationships, and, when signed, a signature. The unit of build, distribution, and installation.
-
Manifest — the JSON document at
.peipkg/manifest.jsoninside a package that declares its identity, relationships, side-effect requirements, and build provenance. The manifest is authoritative for a package's metadata (§5.18). -
Files manifest — the JSON document at
.peipkg/files.jsoncarrying one content hash per regular payload file (§5.25). -
Payload — the tar entries of a package that are not metadata: the files, directories, and symlinks it installs.
-
Repository — a collection of packages addressable as a unit, identified by its base URL.
-
Repository descriptor — the small JSON document at a well-known path within a repository declaring its identity, signing keys, and the locations of its indexes (§5.31).
-
Index — a signed JSON document listing packages available from a repository. Every repository publishes two: an active index (§5.33) listing the current version of each package, and an archive index (§5.35) listing every version ever shipped.
-
Virtual name — a capability name, rather than a package name, that a package may require or provide (§5.4).
-
Role — a virtual name that several installed packages may contend to own on the filesystem, with at most one holding it (§5.23).
-
Claim — the binding of a contended filesystem name (a claim path) to a file supplied by the package that holds a role (a target).
-
Holder — the single installed package that currently owns a role. A role with no holder is unheld.
-
Side-effect declaration — a manifest flag naming a standard maintenance operation to be invoked after install, drawn from a closed set (§5.24).
-
Installation root — a self-contained filesystem tree into which packages are installed. The default root is the system root; a system may define others (§5.19).
-
Epoch, upstream version, peios revision — the three components of a version string (§5.5).
-
Trust anchor — a key fingerprint supplied to a consumer out-of-band, against which a repository's descriptor signature is first verified (§5.37).
5.3 Package Names
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A package's name identifies it within a repository and across every repository that may serve it.
5.3.1 Character set #
A package name MUST consist of ASCII characters drawn from:
- lowercase letters
a–z - digits
0–9 - hyphen
- - period
. - plus sign
+
A package name MUST NOT contain uppercase letters, whitespace, underscores, or any character outside that set.
5.3.2 Structure #
A package name MUST start with a lowercase letter or a digit, and MUST end with a lowercase letter, a digit, or a plus sign.
The hyphen and the period are separator characters. The plus sign is
not a separator but an ordinary name character: it is intrinsic to names
such as libstdc++ and g++, so it MAY repeat and MAY end a name.
A package name MUST NOT contain two consecutive separators — --, ..,
-., or .-.
A package name MUST be at least 2 and at most 64 characters long.
5.3.3 Case #
Package names are case-sensitive. Because uppercase letters are forbidden, this is equivalent to byte-for-byte equality.
5.3.4 Filename convention #
A package file's name, on disk and in URLs, MUST be:
<name>_<version>_<architecture>.peipkg
The separator between fields is the underscore, and the extension is
.peipkg.
A filename is parsed by splitting at the first underscore and then
at the second: what precedes the first is the name, what lies
between them is the version, and what follows the second — up to the
.peipkg extension — is the architecture. The underscore MUST NOT
appear in the name (§5.3) or in the version (§5.5). It MAY appear in the
architecture (§5.8), and does in x86_64, which is why the architecture
field is defined as the remainder rather than as the text after the last
underscore.
Examples:
nginx_1.26.2-3_x86_64.peipkg
jq_1.7.1-2_x86_64.peipkg
peios-docs_0.22-1_noarch.peipkg
libstdc++_13.2.1-4_x86_64.peipkg
A consumer MUST NOT derive a package's identity from its filename. The manifest is authoritative (§5.18); the filename is a convenience for humans and for static hosting.
5.3.5 Sub-package conventions #
Packages shipping related but separable content SHOULD use a hyphen-suffix convention:
| Suffix | Content |
|---|---|
-doc | Documentation, man pages, examples |
-debug | Debug symbols |
-dev | Headers, static libraries, build-time dependencies |
-source | Corresponding source (§5.14) |
These are advisory. The format does not enforce them, and other suffixes MAY be used for other purposes.
5.4 Virtual Names
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
The name of a dependencies, optional_dependencies, or provides
entry (§5.21) MAY be a virtual name rather than a real package name.
A virtual name expresses a capability that is required or provided but
is not itself a package — most importantly a machine-derived capability
such as an ELF soname or a pkg-config module (§5.22).
conflicts and replaces entries target real packages, and so MUST use
the package-name grammar of §5.3, not the grammar below.
5.4.1 Grammar #
The virtual-name grammar is a strict superset of the package-name grammar, in two respects.
Uppercase letters are permitted. A virtual name often mirrors an
exact machine identifier — libGL.so.1, libICE.so.6, a foreign module
name — which is case-sensitive. Case MUST be preserved: folding it would
be unsound, because a case-sensitive dynamic loader treats libGL.so.1
and libgl.so.1 as distinct.
A namespaced form namespace(argument) is permitted, for
capabilities drawn from a foreign namespace. The namespace is
lowercase letters and digits, beginning with a letter. The argument is
bracketed by parentheses, is non-empty, and may contain letters, digits,
the separators -, ., +, and additionally _, :, and / — so
that pkgconfig(gtk+-3.0), perl(Foo::Bar), and
python3dist(ruamel.yaml) are all well-formed.
Outside the namespaced form, a virtual name uses the package-name
character set extended with the underscore _, which is common in real
sonames (libgcc_s.so.1, libnss_files.so.2). It MUST start with a
letter or a digit and MUST end with a letter, a digit, or +. Unlike a
package name, a virtual name MAY contain consecutive separators, so that
libstdc++.so.6 is well-formed.
A virtual name MUST be at least 2 and at most 128 characters long.
5.4.2 One namespace #
Virtual names share a namespace with real package names. A dependency on
libssl is satisfied by a package literally named libssl, or by any
package whose provides includes libssl.
The namespaced form exists to keep machine-derived capabilities from
colliding with package names: pkgconfig(zlib) is unambiguously the
pkg-config module, never a package.
5.5 Versions
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Every package carries a version string that identifies one build of that package. Version strings have a defined structure and a defined comparison order (§5.6), so that "newer" and "older" are unambiguous across every implementation.
5.5.1 Structure #
[<epoch>:]<upstream>-<peios_revision>
- Epoch — an OPTIONAL non-negative integer, separated from the rest by a colon. Absent means zero.
- Upstream — the version the upstream project assigned, or, for Peios-native software, the version Peios assigned as vendor.
- Peios revision — a REQUIRED positive integer identifying the build of this upstream version produced by the distributor.
1.26.2-3 upstream 1.26.2, revision 3
1.26.2-rc.1-1 upstream 1.26.2-rc.1, revision 1
2:0.5.0-1 epoch 2, upstream 0.5.0, revision 1
0.22-1 upstream 0.22, revision 1 (Peios-native)
5.5.2 Epoch #
The epoch MUST be encoded as ASCII decimal digits with no leading zeros,
except that zero is encoded as the single digit 0. The separator is a
single colon.
Epoch exists solely to override the natural ordering of upstream version strings when an upstream regression makes a later release compare as older than an earlier one. Bumping it SHOULD be a deliberate, documented decision; a routine version update MUST NOT bump it.
5.5.3 Upstream version #
The upstream version is everything between the optional epoch separator and the final hyphen preceding the revision.
It MUST consist of ASCII characters drawn from: letters a–z and
A–Z, digits 0–9, period ., plus sign +, hyphen -, and
tilde ~. It MUST start with a digit or a letter, and MUST NOT contain
whitespace or any character outside that set.
5.5.4 Peios revision #
The peios revision MUST be a positive integer encoded as ASCII decimal digits with no leading zeros. It is incremented when the distributor produces a new build of the same upstream version — a backported security patch, a build-configuration change, a dependency bump, a packaging fix.
The first revision of any upstream version MUST be 1. Revision 0 is
reserved and MUST NOT appear in a published package.
5.5.5 Parsing #
A version string is parsed as follows:
- If the string contains a colon, split at the first colon: what precedes it is the epoch, what follows is the remainder. Otherwise the epoch is 0 and the remainder is the whole string.
- Split the remainder at the last hyphen: what follows is the peios revision, what precedes is the upstream version.
- The peios revision MUST parse as a positive integer.
- The upstream version MUST satisfy the constraints above.
A version string that does not parse is invalid, and an implementation MUST reject it.
5.5.6 Stability #
The comparison algorithm of §5.6 is frozen. Any two conforming implementations MUST produce identical comparison results for every pair of valid version strings. An implementation that disagrees with another on any such pair is non-conformant, whichever of the two is at fault.
5.6 Comparing Versions
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Two version strings are compared in three stages:
- Compare epochs as integers. If they differ, the higher epoch is greater.
- If equal, compare upstream versions by the algorithm below.
- If equal, compare peios revisions as integers. The higher revision is greater.
- If all three are equal, the versions are equal.
5.6.1 Tokenising the upstream version #
A tokeniser walks the upstream string left to right and emits segments:
- The non-alphanumeric characters
.,+,-, and~are separators and belong to no segment. - A maximal run of digits forms a numeric segment.
- A maximal run of letters forms an alphabetic segment.
- A transition between a digit and a letter ends the current segment and begins a new one.
5.6.2 Pre-release segments #
A segment is a pre-release segment if it falls at or after the earlier of:
- the first
~separator — the tilde and every segment following it; or - the first recognised pre-release token: a segment whose token carries a rank of 0 to 4 in the table below, that segment and every segment following it.
Once the pre-release tail begins it extends to the end of the upstream
version: every later segment is a pre-release segment, whatever the
separators between them. A - separator is an ordinary separator; it is
not itself a pre-release marker.
| Upstream | Segments |
|---|---|
1.26.2 | 1, 26, 2 |
1.0.0-rc.1 | 1, 0, 0, rc (pre), 1 (pre) |
1.0~rc1 | 1, 0, rc (pre), 1 (pre) |
16beta1 | 16, beta (pre), 1 (pre) |
5.6.3 Pre-release rank #
| Token | Rank |
|---|---|
dev | 0 |
alpha | 1 |
a | 1 |
beta | 2 |
b | 2 |
pre | 3 |
rc | 4 |
| any other alphabetic token | 5 |
Rank 0 sorts lowest. Rank lookup MUST be case-insensitive: Alpha,
ALPHA, and alpha all carry rank 1.
5.6.4 Comparing two segments #
The pre-release flag is compared first, before the kinds. If exactly one of the two segments is a pre-release segment, that segment is the lesser, whatever either segment contains. A pre-release segment sits at or after the point where the version was marked as preceding a release, and that is a property of position rather than of content.
When both segments carry the same flag — both pre-release, or neither — their kinds decide:
- Both numeric — compare as integers. Leading zeros are insignificant.
- Both alphabetic — compare by pre-release rank. When the ranks are
equal:
- at a rank of 0 to 4, the segments are equivalent. The table assigns several tokens to one rank as aliases, so two segments at the same recognised rank sort equal whichever alias appears.
- at rank 5, the segments tiebreak by ASCII byte order against other rank-5 tokens.
- One numeric, one alphabetic — the alphabetic segment is the lesser if the pair is a pre-release pair, and the greater if it is not. (Where only one of them is a pre-release segment, the rule above has already decided.)
5.6.5 Unequal lengths #
When the segments of one version run out and every common segment compared equal, the next segment of the longer sequence decides. Its pre-release flag decides it, and its kind is irrelevant:
| Next segment in the longer | Result |
|---|---|
| a pre-release segment | the shorter is greater |
| anything else | the shorter is less |
| Example tail | Result | |
|---|---|---|
~1 | numeric, pre-release | the shorter is greater |
~rc | alphabetic, pre-release | the shorter is greater |
.1 | numeric | the shorter is less |
-foo | alphabetic, rank 5 | the shorter is less |
5.6.6 Worked examples #
| A | B | Result | Why |
|---|---|---|---|
1.0~2 | 1.0-2 | A < B | the pre-release flag decides before the kinds |
1.0~foo | 1.0-foo | A < B | the same, for two rank-5 tokens |
1.0 | 1.0 | A = B | identical |
1.0 | 2.0 | A < B | numeric segment differs |
1.10 | 1.9 | A > B | numeric, not lexical |
1.0 | 1.0.1 | A < B | longer continues numerically |
1.0 | 1.0-rc.1 | A > B | longer continues with a pre-release |
1.0-rc.1 | 1.0-rc.2 | A < B | numeric segment within the tail |
1.0-alpha | 1.0-beta | A < B | rank 1 < rank 2 |
1.0-rc | 1.0-pre | A > B | rank 4 > rank 3 |
1.0a1 | 1.0a2 | A < B | numeric within a concatenated tail |
1.0a1 | 1.0b1 | A < B | rank 1 < rank 2 |
1.0~rc1 | 1.0 | A < B | the tilde forces a pre-release |
1.0~1 | 1.0 | A < B | the tilde forces a pre-release, numeric or not |
5.2~20240101 | 5.2 | A < B | a dated snapshot precedes its release |
0:1.0 | 1:0.5 | A < B | epoch dominates |
1.0-1 | 1.0-2 | A < B | peios revision differs |
1.0-foo-1 | 1.0-1 | A > B | foo is rank 5, sorting after a number |
5.7 Version Constraints
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A version constraint restricts which versions of a package satisfy a relationship (§5.21).
5.7.1 Operators #
| Operator | Meaning |
|---|---|
= | exactly equal |
> | strictly greater than |
>= | greater than or equal |
< | strictly less than |
<= | less than or equal |
!= | not equal |
Comparison is by §5.6 in every case.
A bare version string with no operator is equivalent to =.
5.7.2 Combining #
Multiple expressions within one constraint string are separated by commas and combined with logical AND. A version satisfies the constraint if and only if it satisfies every expression.
libssl >= 3.0
libssl >= 3.0, < 4.0
nginx = 1.26.2-3
Whitespace around operators and commas is optional and MUST be ignored.
A constraint string MUST parse as one or more operator-and-version expressions separated by commas. One that does not parse is invalid, and an implementation MUST reject it.
5.7.3 Revision-relaxed operands #
A constraint's version operand MAY omit the -<revision> that a
complete version string otherwise requires.
An operand written without a revision — >= 3.0 — constrains the epoch
and the upstream version only. A candidate satisfies it whenever its
epoch and upstream version satisfy the operator, whatever its revision.
An operand written in full constrains the revision as well.
5.8 Architectures
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A package's architecture identifies the instruction-set architecture its binaries were built for. It is a separate identifier from the name and the version.
5.8.1 Identifier format #
An architecture identifier MUST consist of lowercase letters a–z,
digits 0–9, and the underscore _. It MUST start with a lowercase
letter and MUST NOT exceed 16 characters.
5.8.2 Defined architectures #
| Identifier | Meaning |
|---|---|
x86_64 | 64-bit x86 (AMD64, Intel 64) |
aarch64 | 64-bit ARM (ARMv8-A or later) |
noarch | architecture-independent |
An implementation MUST recognise all three. x86_64 is the primary
target; every other architecture is secondary in this version of the
specification.
Additional identifiers MAY be defined in a future version. A new
identifier MUST satisfy the format above and SHOULD be the canonical
Linux machine name — the value uname -m reports — where one exists.
5.8.3 Triplets #
Each architecture identifier that is not noarch has a corresponding
triplet, used in the install paths where arch-specific content is
namespaced (§5.15):
<identifier>-linux-peios
| Identifier | Triplet |
|---|---|
x86_64 | x86_64-linux-peios |
aarch64 | aarch64-linux-peios |
noarch has no triplet form, and architecture-independent payload MUST
NOT be installed under an arch-namespaced path.
5.8.4 Architecture-independent packages #
The noarch identifier denotes a package whose payload contains no
architecture-dependent content: documentation, configuration templates,
scripts in interpreted languages, or metadata only.
A package MUST NOT declare noarch if its payload contains compiled
binaries, shared libraries, or any other content whose semantics depend
on the target architecture.
5.8.5 Installability #
Each Peios system has a single primary architecture, fixed at install time.
- A package whose architecture equals the system's primary architecture MAY be installed.
- A package whose architecture is
noarchMAY be installed on any system. - A package whose architecture is neither MUST NOT be installed.
5.9 Document Conventions
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Every artifact this chapter defines — the manifest, the files manifest, the signature envelope, the repository descriptor, and both indexes — is a JSON document. The rules below apply to all of them.
5.9.1 JSON #
Documents conform to RFC 8259 and are UTF-8 encoded (RFC 3629). Field
names are lowercase with underscores between words: schema_version,
never schemaVersion. Field order is not significant.
Unknown fields MUST be ignored on parse, so that the format can be extended compatibly (§5.38). The signature envelope (§5.28) is the one exception, and mandates strict parsing.
5.9.2 Parser hardening #
A consumer's JSON parser processes attacker-supplied input. It MUST therefore enforce the following, on every document defined in this chapter:
- Duplicate keys in any object MUST cause the document to be rejected. A parser that silently takes first-wins or last-wins is not conformant.
- Integer fields MUST fit in the unsigned 64-bit range and MUST NOT use exponent notation.
- Nesting depth MUST be capped at 64; a document exceeding that depth MUST be rejected.
- A string value MUST NOT exceed the document size limit applicable to its containing artifact (§5.A).
- A Unicode escape within a string MUST resolve to a valid code point per RFC 8259 §7.
5.9.3 Hashes #
Hash values are encoded in lowercase hexadecimal unless stated
otherwise. Hash algorithms are identified by their IANA-registered names
(sha256, blake3).
5.9.4 Signatures #
Signatures use Ed25519 as defined in RFC 8032 unless stated otherwise. Signature values are encoded in base64 (RFC 4648 §4) without padding. A base64 value carrying padding MUST be rejected.
5.9.5 Strings #
String comparison uses byte-for-byte equality unless stated otherwise.
5.9.6 URLs #
URLs follow RFC 3986. Relative URLs in a repository index are resolved against the repository descriptor's URL (§5.36).
5.9.7 Compression #
Compression uses the Zstandard format (RFC 8478). This specification does not constrain the compression level.
5.10 The Container
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A package is a single file: a tar archive compressed with Zstandard.
5.10.1 Extension #
A package file's extension MUST be .peipkg. There is no intermediate
.tar form; a producer emits the compressed form directly, and the
compressed file is the whole artifact.
5.10.2 Tar format #
The tar archive MUST conform to the POSIX pax interchange format (IEEE Std 1003.1-2017, Chapter 14).
5.10.3 Compression #
The archive MUST be compressed with Zstandard (RFC 8478).
The compression level is at the producer's discretion. zstd is
deterministic at every level, so a producer MAY choose any level to
trade build time against on-wire size. Levels 19 and above, including
--ultra, increase build time substantially for a smaller result; level
3 is a common default.
5.10.4 Reproducibility #
A package MUST be reproducible: given identical source inputs, an identical build environment, identical metadata — including the build timestamp recorded in the manifest — and identical compression parameters, two independent producers MUST produce byte-identical package files.
The determinism rules of §5.11 are what make this achievable at the format level. They are necessary rather than sufficient: they constrain what the archive looks like, not how the producer arrived at its contents.
Byte-identity is a property of the uncompressed tar stream and of the compression applied to it. This specification fixes the former completely and the latter not at all: the compression level, the Zstandard implementation, its version, and its frame parameters all affect the resulting bytes and are none of them constrained here. Two producers seeking byte-identical output MUST therefore agree on their compression parameters out of band. What the format guarantees unconditionally is that the signed bytes — the uncompressed tar prefix of §5.28 — are identical, so a signature survives recompression at any level.
5.10.5 Streaming #
A consumer MAY process the archive as a stream. The internal layout (§5.12) places metadata before payload precisely so that a consumer can read a package's identity and reject a mismatched package without buffering the payload.
5.10.6 No outer wrapping #
The compressed archive contains tar entries and nothing else: no enclosing directory, no concatenated archives, no container metadata outside the tar entries themselves.
5.11 Determinism
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
To make a package byte-reproducible (§5.10), the tar archive MUST obey every rule below. A consumer MUST reject a package that violates any of them.
- Tar entries MUST be ordered lexicographically by the entry name as
written into the tar header, compared byte-for-byte over its UTF-8
bytes. A directory's entry name carries a trailing
/, and that slash participates in the comparison. - Every entry's modification time MUST equal the value of
build.timestampin the manifest (§5.18), which MUST NOT carry sub-second precision. - Every entry's owner numeric ID and group numeric ID MUST be 0.
- Every entry's owner name and group name MUST be the string
root. - Entries MUST NOT carry extended attributes. Security descriptors are applied at file-creation time (§5.20), never through tar attributes; a binary signature travels as a detached-signature payload entry (§5.16), from which the consumer derives the attribute; other install-time attributes are applied through side-effect declarations (§5.24) or by higher-level mechanisms outside this specification.
- Entry permission bits MUST be
0777for every entry, with the setuid and setgid bits cleared (§5.16). - PAX extended header records, when present, MUST appear in a fixed
canonical order:
pathfirst, thenlinkpathif present, then any other record sorted by record name lexicographically. - The tar header magic MUST be
ustar\0and the version MUST be00. - The
devmajoranddevminorheader fields MUST be 0 for every entry type this specification permits — none of which is a device entry. - Header padding bytes MUST be NUL (
0x00). - PAX global header records (typeflag
g) MUST NOT appear. - PAX extended header records (typeflag
x) MUST appear only when an entry'spathexceeds the ustar 100-byte limit, in which case apathrecord is emitted, or itslinknameexceeds that limit, in which case alinkpathrecord is emitted. A record with any other key MUST NOT be emitted. - A path exceeding the ustar 100-byte limit MUST be carried by a
pathrecord. The ustarprefixfield MUST NOT be used to split such a path acrossprefixandname. - An extended header entry's own name MUST be the containing
directory's path, then
PaxHeaders.0/, then the base name of the entry it describes. For an entry at the archive root the directory part is absent.
Rules 13 and 14 exist because a tar library given a long path may legitimately choose either encoding, and either choice produces a different byte stream from the same input. Determinism requires the choice be made here rather than by whichever library a producer reached for.
5.12 Internal Layout
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A package's tar entries divide into metadata entries under a reserved prefix and payload entries — the files that will be installed.
5.12.1 The reserved prefix #
Every metadata entry MUST appear under the path prefix .peipkg/ at the
archive root. That prefix is reserved: a payload entry MUST NOT use any
path beginning with .peipkg/, and no payload entry may be named
literally .peipkg.
5.12.2 Required entries #
| Entry path | Purpose | Section |
|---|---|---|
.peipkg/manifest.json | Authoritative package metadata | §5.18 |
.peipkg/files.json | Per-file integrity manifest | §5.25 |
.peipkg/signature | Inline package signature | §5.28 |
.peipkg/manifest.json and .peipkg/files.json MUST be present in
every package. .peipkg/signature MUST be present in every signed
package; a package without it is unsigned (§5.28).
5.12.3 Entry order #
Tar entries MUST appear in exactly this order:
.peipkg/manifest.json.peipkg/files.json- Any optional metadata entries, sorted lexicographically by path
- All payload entries, sorted lexicographically by path (§5.11 rule 1)
.peipkg/signature
The manifest comes first so that a streaming consumer can read a package's identity and reject a mismatched one — wrong name, wrong version, wrong architecture — before reading any payload.
The signature comes last because it signs everything preceding it
(§5.28). A consumer MUST reject a package in which any named entry
follows .peipkg/signature.
5.12.4 Optional metadata entries #
A package MAY carry additional entries under .peipkg/. The set this
specification recognises is fixed at the three above; an unrecognised
entry under .peipkg/ MUST be ignored on parse and MUST NOT prevent
installation.
When present, an optional metadata entry MUST appear between
.peipkg/files.json and the first payload entry.
5.12.5 Permitted entry types #
A payload entry MUST be one of:
- a regular file (typeflag
0or\0) - a directory (typeflag
5) - a symbolic link (typeflag
2)
Any other entry type MUST cause the package to be rejected. This
excludes hardlinks (typeflag 1), character devices (3), block
devices (4), FIFOs (6), contiguous files (7), and every
vendor-specific type.
5.13 Payload Paths
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A payload entry's tar path is its install location, resolved against the
installation root (§5.19). A tar entry at usr/bin/nginx installs to
/usr/bin/nginx.
5.13.1 Constraints #
A payload path MUST:
- be relative — it MUST NOT begin with
/ - contain no segment equal to
.or.., or any encoding thereof - be valid UTF-8 (RFC 3629)
- contain no NUL byte (
0x00) and no ASCII control character (0x01–0x1F,0x7F) - contain no backslash (
\) - be in Unicode Normalization Form C, per Unicode 16.0
- have every component at most 255 bytes when encoded as UTF-8
- be at most 4096 bytes in total when encoded as UTF-8
- have at most 256 components
- not begin with
.peipkg/, and not be literally.peipkg(§5.12)
A consumer MUST validate every payload path against these constraints before any further processing of the entry. A package containing a non-conforming payload path MUST be rejected.
5.13.2 No canonicalisation #
Path resolution MUST NOT canonicalise away .. or . segments by
interpretation. Such segments are forbidden above; any appearance is a
format error, not a question of path canonicalisation.
5.13.3 Empty payloads #
A package MAY have zero payload entries. Such a package carries only metadata; installing it records the package and runs any declared side effects (§5.24).
5.14 Install Destinations
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Peios separates package-owned vendor storage under /usr from the
root-level runtime views such as /bin, /lib, and /sbin. Those
views are filesystem topology assembled by the boot and base-filesystem
layers; they are not package storage. A package installs its files under
/usr, and the runtime topology projects them at their canonical paths.
Within /usr, executables split by kind. /usr/sbin/ holds system
binaries — daemons, init and boot binaries, and service executables
not normally invoked directly by a person. /usr/bin/ holds everything
else, including administrative tools a person does invoke directly, even
those requiring administrator privileges.
5.14.1 Permitted top-level destinations #
| Path | Purpose |
|---|---|
/usr/bin/ | Executables that are not system binaries — user-facing tools, and admin tools invoked directly |
/usr/sbin/ | System binaries — daemons, init and boot binaries, service executables |
/usr/lib/<triplet>/ | Architecture-specific libraries and arch-dependent data (§5.15) |
/usr/lib/debug/ | Separated debug information, mirroring the install paths of the files it describes |
/usr/lib/modules/<release>/ | Kernel content for one kernel release: its modules, and the kernel image, System.map, and build config alongside them |
/usr/lib/firmware/ | Device firmware blobs, addressed by device rather than by host triplet |
/usr/lib/os-release | The freedesktop OS-identity file, at a fixed external contract path |
/usr/libexec/ | Architecture-independent helper executables run by another program rather than by a person |
/usr/share/ | Architecture-independent data |
/usr/include/ | Header files |
/usr/src/debug/ | Debugger source files, mirroring the build's source tree |
/usr/src/dist/ | Corresponding source shipped by -source packages |
/usr/etc/ | Vendor-shipped default configuration for legacy applications — the bottom layer of the /etc merge |
/usr/conf/ | Vendor-shipped defaults for the supplementary configuration of native applications — the bottom layer of the /conf merge |
/var/ | Runtime variable state directories, empty at install time |
/boot/ | /boot/initramfs/, a complete independent root filesystem, and /boot/efi/, the EFI System Partition |
/hooks/ | Initramfs boot hooks, discovered and ordered when the initramfs cpio is packed |
/++/ | Initramfs early-cpio segments, prepended uncompressed ahead of the main archive |
A payload entry MUST NOT install under any other top-level path, unless the package declares itself a special system package (below).
A consumer MUST enforce this at install time. Producer-side validation proves nothing about a package file that arrives from elsewhere.
5.14.2 Notes on individual destinations #
Only the debug/ and dist/ subtrees of /usr/src/ are permitted; the
rest of /usr/src/ is administrator territory.
/usr/etc/ is where package configuration goes. A package never writes
/etc directly, because /etc is a merged view resolving
/usr/etc < /system/retc < /lcl/etc, not storage. /usr/conf/ is
the equivalent bottom layer of the /conf merge (/usr/conf <
/lcl/conf); native software reads the registry directly, so there is
no reconciled layer between them.
/var/ accepts empty directories only, establishing locations a
runtime will write to — /var/log/<service>/, /var/state/<service>/.
Populated content under /var/ is invalid: variable state is owned by
the runtime, not by the package.
/hooks/ is meaningful only in an initramfs root, where the cpio packer
scans it. In an ordinary system root it is an unused permitted
destination.
An entry under /boot/ SHOULD be a symlink whose target resolves to a
regular file under one of the other permitted destinations — typically
/usr/lib/<triplet>/ for a kernel image, initramfs, or device tree.
/boot/ is a discovery directory a bootloader reads, not storage where
real package content lives. This is a SHOULD rather than a MUST because
recovery images and embedded bootloader integrations that cannot follow
symlinks exist; a format-level validator does not enforce it.
A package reaching /boot/initramfs/ is cross-targeting a different
root, not installing into this one (§5.19).
5.14.3 Special system packages #
A few packages exist precisely to lay down the structure these rules protect — the base-filesystem package that mints the runtime mountpoint tree is the archetype. For those, the allowlist is not a guardrail but the thing being installed.
Such a package MAY set special_system_package in its manifest
(§5.18). The declaration waives the layout checks at production time
only. It grants nothing at install time: a consumer MUST refuse an
out-of-layout payload unless the operator has also explicitly opted
in, through a distinct and deliberate act naming that intent.
This is two keys held by two parties. A package may propose its own exemption; only whoever installs it can grant one.
When a consumer meets the declaration without having been given the opt-in, it MUST refuse the package with an error naming the refused request, so that an operator can tell "this package asked for an exemption I did not grant" from "this package is malformed".
/lcl/policy MUST NOT be reachable by this route under any
circumstance. It is the tree whose contents grant authority, and an
exemption that could reach it would convert a structural guarantee into
a policy one.
5.14.4 Drop-in directories #
Several subdirectories of the /etc merge are drop-in directories:
their contents are interpreted as code or configuration by other tools,
notably the side-effect tools of §5.24 and system daemons that read
configuration drop-ins. A package writing into one has indirect
influence on the behaviour of components that read it.
A package from a repository other than the system's official repository
MUST NOT install a file at the top level of the /usr/etc layer of any
of these:
ld.so.conf.d/profile.d/sudoers.d/cron.d/,cron.daily/,cron.hourly/,cron.weekly/,cron.monthly/sysctl.d/modules-load.d/modprobe.d/binfmt.d/- any directory the system declares as a drop-in directory through its configured list
The consumer's drop-in directory list MUST be stored under a security descriptor granting write access only to a recovery-class operator principal, never to the principal performing installs. Operator configuration MAY add entries to the list but MUST NOT remove an entry this specification requires: the list is purely additive.
A non-official-repository package whose payload installs to one of those paths MUST be rejected at install time.
A non-official-repository package MAY install drop-in files under its
own subdirectory of a drop-in path — for example
/usr/etc/ld.so.conf.d/<repo-name>/<package>.conf — provided the
subdirectory is namespaced by both the repository's name and the
package's name, so that two such packages cannot collide.
5.15 The Architecture Triplet
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A package whose architecture is not noarch MUST install all of the
following under /usr/lib/<triplet>/, where <triplet> is the
architecture triplet of §5.8:
- shared libraries (
.so,.so.*) - static libraries (
.a) - loadable modules — plugin shared objects, and kernel modules outside
/usr/lib/modules/ - architecture-dependent helper binaries not on the user's search path
- any other arch-dependent file that is not a user-facing binary
Architecture-independent helper executables — a shell script run by
another program, say — go under /usr/libexec/ instead, which carries
no triplet rule because the rule is scoped to /usr/lib/.
A package whose architecture is noarch MUST NOT install any file under
/usr/lib/<triplet>/. A noarch package containing any of the
categories above is invalid.
5.15.1 Exemptions #
Three arch-dependent payload categories are exempt from the triplet path, because each is addressed by something other than the host triplet:
| Category | Path | Addressed by |
|---|---|---|
| Kernel content | /usr/lib/modules/<release>/ | kernel release |
| Device firmware | /usr/lib/firmware/ | device |
| Separated debug information | /usr/lib/debug/ | the install path of the file it describes |
A noarch package MUST NOT install under /usr/lib/modules/ or under
/usr/lib/debug/: kernel content and debug information are both
arch-dependent. /usr/lib/firmware/ carries no such restriction,
firmware being opaque data rather than host-architecture content.
Debug files mirror the full install path of what they describe. The
debug information for /usr/bin/foo is
/usr/lib/debug/usr/bin/foo.debug; for /usr/lib/<triplet>/libfoo.so.1
it is /usr/lib/debug/usr/lib/<triplet>/libfoo.so.1.debug. Debug files
MAY additionally be indexed by build ID under
/usr/lib/debug/.build-id/.
The freedesktop os-release file is a fourth exemption of a different
kind: it installs at exactly /usr/lib/os-release, a fixed external
contract path the ecosystem hard-codes. Unlike debug information it is
arch-independent, so a noarch package — the OS-identity package —
MAY ship it. It is conventionally paired with a /usr/etc/os-release
symlink, which the /etc merge projects to /etc/os-release.
5.15.2 Source #
The debugger source files that debug information references install
under /usr/src/debug/, not under /usr/lib/. Source is
architecture-independent, so /usr/src/debug/ carries neither a triplet
rule nor the noarch restriction: it is a plain permitted destination
that both arch-specific and noarch packages MAY use. The same applies
to /usr/src/dist/, the home of corresponding-source packages.
Files retained inside /usr/src/dist/ are reconstruction inputs rather than
installed library providers. Captured upstream or vendored inputs MAY include
library-shaped test fixtures or import archives for other target platforms
(such as Cargo's Windows crates), including in a noarch source package. Their
.so or .a suffix does not make that reserved source-data tree a runtime
library installation. The normal library-placement rule still applies outside
this subtree; source data remains subject to path and symlink containment.
Automatic ELF and pkg-config capability derivation MUST ignore /usr/src/
inputs: their libraries and .pc fixtures do not provide installed interfaces
or create runtime dependencies of a source package.
5.15.3 Architecture-independent data #
/usr/share/ holds architecture-independent files shared across every
architecture of a system: documentation, man pages, locales,
configuration templates, and static data such as icons, images, and
fonts.
Both noarch and arch-specific packages MAY install under
/usr/share/. A file installed there by an arch-specific package MUST
be byte-identical across every architecture build of the same upstream
version.
5.16 Payload Entries
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
5.16.1 Permissions #
Tar entry permission bits in a package are distribution-format metadata only. They establish no access control on the installed file. Access control is the consumer's responsibility: on Peios, through a security descriptor applied at file-creation time (§5.20); on any other system extracting a package for inspection or migration, through that system's native mechanism applied after extraction.
Every payload entry's permission bits MUST be 0777. Any other value
MUST cause the package to be rejected.
The setuid and setgid bits MUST NOT be set on a payload entry. Privilege escalation on Peios is mediated by the kernel's access-control subsystem, not by filesystem-level setuid; a setuid bit is meaningless to the access-check path and MUST NOT appear in installed content.
5.16.2 Empty directories #
A package MAY install an empty directory: a tar entry of type directory
with no content. Empty directories establish paths a runtime will need,
and are the only content permitted under /var/ (§5.14).
5.16.3 One package per path #
Two packages MUST NOT install a file at the same install path. A consumer MUST detect the collision and reject the second install.
A package MAY install content into a directory another package created; directory creation is idempotent. The rule applies to non-directory entries only.
The one exception is a claim link (§5.23), which belongs to the consumer rather than to any package and is materialised only at a path no installed package owns.
5.16.4 Detached signatures #
A payload entry whose path ends in .peios.sig is a detached
signature: the 3310-byte binary-signature blob of PSPK chapter 3 for
the entry whose path is the same with that suffix removed (its
target), stored as ordinary payload bytes. The suffix is reserved;
a package MUST NOT use it for any other purpose.
This is how a signature reaches a file that cannot carry one in an ELF
section. §5.11 forbids extended attributes on tar entries, so the
security.peios.sig placement PSPK defines cannot travel inside a
package; the detached entry carries the same blob as payload instead,
and the consumer derives the attribute from it.
A consumer MUST reject a package in which a detached signature:
- has no target — the path without the suffix is not a payload entry of the same package, or is a directory or symlink entry;
- is not exactly 3310 bytes, or its first byte is not
0x01; or - has a target whose first four bytes are
\x7fELF. An ELF file carries its signature in its.peios.sigsection; giving it a detached signature as well would leave two carriers for one file, and PSPK's lookup order would silently ignore the second.
A consumer that installs the target MUST, as part of creating the file
and before the file becomes visible at its install path, set the
extended attribute security.peios.sig on it to exactly the detached
entry's bytes, and MUST NOT create a file at the detached entry's own
path. The detached entry is transport for the attribute, not content:
it is not installed, is not recorded as a file the package owns, and
takes no part in the one-package-per-path rule above. Removing or
replacing the target removes or replaces the attribute with it.
The consumer verifies nothing about the signature beyond its shape. The blob is the signer's assertion and the kernel's to verify; a consumer that can install the package cannot generally hold the keys that would let it check the blob, and MUST NOT refuse a package because it cannot.
5.16.5 Forward compatibility #
The triplet path convention of §5.15 is designed so that a future multi-architecture system MAY install foreign-architecture packages alongside native ones without filesystem-level collisions.
In this version only one architecture's packages may be installed on a given system at a time (§5.8). The triplet convention applies regardless, so that a package conforming to this version stays forward-compatible with such an extension.
5.17 Symlinks
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Symlinks are first-class payload entries. The tar entry's linkname is the symlink target.
5.17.1 Target constraints #
A symlink target MUST be a relative path.
A symlink target MUST resolve, when joined with the symlink's parent directory, to a path that is either within the package's own payload tree or under one of the permitted top-level install destinations of §5.14. An absolute target is forbidden, as is a target whose resolution escapes those destinations entirely.
A symlink target is subject to the same path-validity constraints as a payload path (§5.13): valid UTF-8, no NUL bytes, no ASCII control characters, no backslashes, NFC normalisation, and the length limits.
A consumer MUST validate every symlink target against these constraints before extracting the entry. A package containing a non-conforming symlink target MUST be rejected.
5.17.2 Cross-package targets #
A producer MAY emit a symlink whose target resolves into a different
package's payload tree, provided the resolved path is under a permitted
destination. The canonical case is the conventional library split, where
a -dev package ships a developer link (libfoo.so) whose target
(libfoo.so.1) lives in the corresponding runtime package.
A producer SHOULD declare the target's owning package as a dependency,
so that the target is present at extraction time. The format does not
record this relationship at the symlink level; it is captured at the
package level through dependencies (§5.21).
5.17.3 Integrity #
A symlink has no content body, and so is not hashed in the files manifest (§5.25). Its target is integrity-checked directly: the linkname stored in the tar header is what the consumer compares, and that header is inside the signed bytes (§5.28).
5.17.4 Security descriptors #
A symlink does not carry an independent security descriptor. Access to a symlink is governed by access to its target. A security descriptor override (§5.20) MUST NOT target a symlink entry.
5.18 The Manifest
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
The manifest is the authoritative metadata for a package: its identity,
its relationships, its side-effect requirements, and its build
provenance. It is a JSON document at .peipkg/manifest.json.
5.18.1 Schema #
5.18.2 Required fields #
| Field | Type | Description |
|---|---|---|
schema_version | integer | MUST be 1 in this version. |
name | string | Package name conforming to §5.3. |
version | string | Version conforming to §5.5. |
architecture | string | Architecture identifier conforming to §5.8. |
dependencies | array | Required dependencies. MAY be empty; MUST be present. |
conflicts | array | Conflicting packages. MAY be empty; MUST be present. |
size_installed | integer | Total size in bytes of the installed payload. |
build | object | Build provenance. |
A manifest missing any required field MUST be rejected.
The package format treats name as an opaque canonical identifier within the
§5.3 grammar; it does not derive or authenticate namespace ownership. Peios
catalogues assign reverse-DNS names whose leading segments identify the
upstream namespace and whose final segment is the familiar package name, such
as com.amd.amd-ucode. Peios-originated packages use org.peios. Dependency
and package-operation inputs that identify a concrete package carry the
complete canonical name; virtual capabilities retain their own names.
Repository signatures and build provenance, rather than the spelling of
name, establish who produced a particular package.
5.18.3 Optional fields #
| Field | Type | Description | Absent means |
|---|---|---|---|
description | string | One-line human-readable description. | empty string |
license | string | SPDX identifier or expression. | empty string |
license_class | string | Which of the closed set of licence classes (§5.a2) the package's terms fall into: unknown, free, firmware or proprietary. | unknown |
homepage | string | URL of the upstream project. | empty string |
default_root | string | The root a top-level install of this package lands in when the operator names none (§5.19). | the operator's current root |
special_system_package | boolean | Declares the package exempt from the §5.14 layout rules at production time (§5.14). | false |
alternate_upgrade | object | Declares that the package is meant to be installed and upgraded through a path outside the package manager (see "The alternate_upgrade object" below). | none |
optional_dependencies | array | Dependencies that enhance but are not required. | empty array |
provides | array | Virtual names this package satisfies. | empty array |
replaces | array | Packages this one supersedes. | empty array |
side_effects | array | Maintenance operations to invoke (§5.24). | empty array |
sd_overrides | array | Per-entry security descriptor overrides (§5.20). | empty array |
A manifest carrying an unknown field MUST NOT be rejected; the unknown field MUST be ignored (§5.9).
5.18.4 The alternate_upgrade object #
| Field | Required | Description |
|---|---|---|
message | yes | Text shown to the operator in place of proceeding. Non-empty UTF-8, at most 1024 bytes; MAY contain newlines and MUST NOT contain other control characters. |
A package that sets alternate_upgrade states that it is meant to be
installed and upgraded by some means other than a routine package
operation — an operating-system edition moved by a dedicated tool that
also reconciles what the package manager deliberately does not touch,
for example. The package manager grants nothing on the declaration's
behalf. Its whole effect is on the consumer:
-
A request that names the package —
installorupgradewith the package as an argument — or any install or named-upgrade plan whose operations would install or upgrade the package as a dependency, MUST be refused: nothing is applied, and the consumer MUST report the package's name, itsmessageverbatim, and this warning:Alternate upgrade paths may bypass normal peipkg protections; ensure you fully trust the authors of the package before running.
-
An upgrade of every installed package MUST hold the package back at its installed version, report it as in rule 1, and proceed with the rest of the upgrade. Holding back is not a failure.
-
A consumer MUST provide a deliberate, per-invocation override (
--bypass-alternate-upgradein the reference implementation) under which rules 1 and 2 do not apply. The override is the intended way for the out-of-band tool to drive the package manager; it is never a default and is never persisted.
Composing a root from nothing — an image builder populating an empty tree, rather than a consumer changing an installed system — is not an install or upgrade in the sense above, and an image builder MUST ignore the field.
5.18.5 The build object #
| Field | Required | Description |
|---|---|---|
timestamp | yes | RFC 3339 timestamp of the build. MUST be UTC, MUST end with Z, and MUST NOT carry sub-second precision. |
farm_id | yes | Identifier of the build farm that produced this package. |
source_ref | yes | Reference to the build inputs, sufficient to reproduce the build. |
source_package | no | Name of the corresponding-source package produced from the same recipe and inputs (§5.15). |
recipe_ref | no | VCS identity of the recipe tree the build ran from — for example git:<commit>, suffixed +dirty when the work tree held uncommitted changes. |
builder | no | Identity and revision of the producing tool, for example pekit/<revision>. |
A consumer MUST treat an absent optional field as the empty value.
timestamp is also the modification time of every tar entry (§5.11
rule 2). A producer MUST set both identically.
source_ref is producer-defined but SHOULD be a machine-resolvable
reference. The conventional form is a version-control URL with an
explicit ref:
git+https://git.peios.org/sources/nginx#refs/tags/v1.26.2-3
5.18.6 Field constraints #
description, when present, MUST consist only of printable ASCII in the
range 0x20–0x7E. ASCII control characters and non-ASCII bytes MUST
NOT appear. It SHOULD be a single line under 80 characters; longer
descriptions belong in upstream documentation.
license, when present, SHOULD be a valid SPDX expression. A producer
MAY use another form; this specification does not validate license
strings. A LicenseRef- term names a licence with no SPDX identifier;
a producer using one SHOULD ship the licence text under
/usr/share/licenses/<name>/ in the same package.
license_class, when present, MUST be one of unknown, free,
firmware, or proprietary; any other value MUST cause the package to
be rejected. Absent means unknown, and a consumer MUST treat the two
identically. A producer SHOULD declare a class other than unknown
whenever license contains a LicenseRef- term, since that is
precisely the case in which the expression alone cannot say.
homepage, when present, MUST be a syntactically valid URL per RFC 3986
and MUST use the https or http scheme. Any other scheme MUST cause
the package to be rejected.
size_installed MUST be a non-negative integer, and MUST equal the sum
of the size fields of every entry in the files manifest (§5.25). A
consumer MUST verify that equality and MUST reject a package where it
does not hold.
5.18.7 Authoritative status #
The manifest is authoritative for a package's metadata. Where it disagrees with any other source — the repository index, the filename, secondary documentation — the manifest MUST be treated as correct, and the disagreement MUST be reported (§5.32).
5.18.8 Encoding #
The manifest MUST be UTF-8 encoded JSON and MUST end with a single newline.
A producer that intends its packages to be byte-reproducible MUST
serialise the manifest canonically: compact, with no insignificant
whitespace, with HTML-escaping of <, >, and & disabled, with
fields in the declaration order of the schema above, and with every
optional field either always emitted or never emitted for a given
producer.
5.19 Installation Roots
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
An installation root is a self-contained filesystem tree into which packages are installed. The default root is the system root; a system MAY define additional named roots — an initramfs image built and maintained alongside the main system is the motivating case.
How roots are registered, and how a name resolves to a filesystem location, is consumer mechanics and is not part of the package format.
5.19.1 Root references #
A root reference is the string form by which a manifest names a
root. Within a manifest a root reference MUST be a named reference:
one or more segments separated by ., where each segment matches
[a-z0-9][a-z0-9_-]*. Nesting is expressed by further segments, so
initramfs.subroot names the root subroot registered within the root
initramfs.
A root reference in a manifest MUST NOT be an absolute or relative filesystem path. A package names roots and never dictates a filesystem location: placement is the installing system's prerogative.
A manifest whose root reference is not syntactically valid is invalid and MUST cause the package to be rejected. Whether the named root exists is a consumer-side resolution concern, not a format-validity one.
5.19.2 default_root #
The manifest's default_root field (§5.18) governs only the
placement of a top-level install of the package — an operator request
naming this package directly, with no explicit root.
It has no effect when the package is pulled in as a dependency;
dependency placement is governed by the depending package and by the
dependency's own root field (§5.21). An explicit operator-supplied
root always overrides default_root.
5.19.3 Satisfaction is per-root #
The identity of a satisfier is the pair (name, root). The same
package name installed in two different roots is two independent
satisfactions, possibly at different versions, and a dependency is
satisfied only by an installation in the named — or defaulted — root.
A constraint or architecture qualifier is evaluated against that
installation.
5.20 Security Descriptor Overrides
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Every installed file and directory carries a security descriptor. A consumer applies it at file-creation time, through the kernel's file-creation interface — never through a tar attribute, which §5.11 rule 5 forbids outright.
5.20.1 The default is inheritance #
When a payload entry has no override, a consumer MUST create the entry without supplying an explicit security descriptor, so that the kernel computes one by inheritance from the parent directory's inheritable entries at creation time.
Inheritance is the default for the overwhelming majority of installed entries, and most packages declare no overrides at all. An override is appropriate when a file needs more restrictive access than its parent would give it, when it needs explicit access for a principal absent from the parent's inheritable entries, or when a directory needs to begin a new inheritance scope.
5.20.2 Declaring an override #
An entry in the manifest's sd_overrides array has the form:
| Field | Description |
|---|---|
path | Payload-relative path, matching a tar entry exactly. |
sd | Base64-encoded binary self-relative security descriptor, per RFC 4648 §4 without padding. |
The sd_overrides array MUST be sorted lexicographically by path, and
MUST NOT contain two entries with the same path.
path MUST refer to a regular-file entry or a directory entry. An
override MUST NOT target a symlink entry, which carries no independent
descriptor (§5.17).
An override referring to a non-existent payload entry, or to a symlink entry, is invalid and MUST cause the package to be rejected.
sd MUST decode to a syntactically valid binary self-relative security
descriptor. One whose decoded bytes do not parse is invalid and MUST
cause the package to be rejected.
A consumer MUST perform all three of those checks — entry existence, entry type, and descriptor parseability — before installing anything from the package. Deferring them to the moment the descriptor is applied turns a malformed package into a partially completed install.
5.20.3 The consumer's policy obligation #
The kernel validates that a declared descriptor is well-formed. It does not validate that the producer of a package had any authority to declare that descriptor on behalf of the principals it grants access to. A package can therefore declare a descriptor granting access to any principal the system knows about. The format treats the bytes as opaque; whether a given package may declare a given descriptor is policy, and that policy is the consumer's to enforce.
A consumer MUST enforce a per-repository override policy:
- Before applying any override, the consumer MUST surface it to the operator in human-readable form, including the payload path, the principals and rights granted, and a diff against what inheritance would have produced.
- For a package from the system's official repository, overrides MAY be applied without per-operation confirmation, but the operator-visible install report MUST list every override applied.
- For a package from any other repository, the consumer MUST require explicit operator confirmation before applying an override that grants rights to a principal outside a configured allowlist. The default allowlist contains the well-known system principals, plus any principal the operator has added to that repository's allowlist. It MUST NOT contain any principal derived from the package itself — from its manifest fields, its build metadata, or its payload. A package cannot elect its own principals into the allowlist.
- A package whose overrides the policy rejects MUST be refused. A consumer MUST NOT silently drop the overrides and proceed with inheritance defaults.
5.20.4 Inherited descriptors are covered too #
The policy applies both to explicitly declared descriptors and to descriptors that result from inheritance from a directory whose own descriptor was declared by any package's overrides.
Specifically: when installing a file under a directory whose descriptor was overridden by any package — from any repository — the resulting inherited descriptor MUST pass the policy as if the installing package had declared it.
5.20.5 Failure #
If file creation fails because the kernel rejects the descriptor — most often because it references a principal the system does not know — the install MUST be treated as failed and any partial state rolled back.
A package MUST NOT be installed into a parent directory whose descriptor denies the caller the access required to create the entry. A consumer detects this at install time and treats it as an install failure.
5.21 Relationships
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A package expresses its relationships to other packages in five manifest fields:
dependencies— packages that must be installed for this one to functionoptional_dependencies— packages that enhance functionality but are not requiredconflicts— packages that must not be installed alongside this oneprovides— virtual names this package satisfies on behalf of dependencies declared elsewherereplaces— packages this one supersedes
5.21.1 Dependency entries #
An entry in dependencies or optional_dependencies:
| Field | Required | Description |
|---|---|---|
name | yes | The depended-on package name (§5.3) or virtual name (§5.4). |
constraint | no | A version constraint per §5.7. Absent means any version satisfies. |
arch | no | An architecture qualifier. Default any. |
root | no | A root reference (§5.19) naming the root this dependency is placed and satisfied in. Absent means the same root as the depending package. |
claims | no | Claim paths this dependency expects a holder to materialise (§5.23). |
root, when present, MUST be a syntactically valid named root
reference — never a filesystem path. An entry whose root is not one is
invalid.
5.21.2 Conflict entries #
An entry in conflicts has the same shape as a dependency entry, minus
root and claims, and expresses incompatibility rather than
requirement: a package MUST NOT be installed simultaneously with any
package matching the entry. A conflict whose constraint is absent
expresses incompatibility with any version of the named package.
5.21.3 The architecture qualifier #
arch restricts the qualified package's architecture. In this version
the only valid value is any, which is the default. Any other value
MUST be rejected.
any means: the qualified package's architecture MUST equal the
depending package's effective architecture, or be noarch.
A depending package's effective architecture is its own architecture
when arch-specific, and the system's primary architecture (§5.8) when
the depending package is noarch. A noarch label describes an
architecture-independent payload, not an architecture-independent
resolution context: a noarch package's dependencies on arch-specific
packages — a script on its interpreter, a meta-package on native tools —
resolve against the concrete system being assembled, exactly as a native
package's do.
5.21.4 Provides entries #
| Field | Required | Description |
|---|---|---|
name | yes | The virtual name provided, conforming to §5.4. |
version | no | The version of the capability provided. Parsed revision-relaxed (§5.7), because a provides version is a capability level rather than a packaging iteration. Absent means any version of the name is provided. |
claims | no | Filesystem targets this package materialises when it holds the named role (§5.23). |
A virtual name that collides with a real package name MAY be provided; both are then valid satisfiers of a dependency on that name.
provides.version SHOULD reflect the providing package's actual
functional compatibility level. A provides.version greater than the
providing package's own version MUST generate an operator warning at
install time, because an inflated provides-version defeats
constraint-based resolution.
The provides relation does not flow transitively: providing
smtp-server does not provide whatever smtp-server itself provides.
5.21.5 Replaces entries #
name is required and MUST conform to the package-name grammar (§5.3).
constraint is optional; absent means this package replaces any version
of the named one.
A replaces entry expresses supersession. During upgrade the replaced package is removed and this one installed in its place: files owned by the replaced package that no longer exist in this one are removed, and files existing in both are updated.
A replaces entry does not imply a conflict. A package MAY both replace and conflict with the same target, but a replaces entry is typically sufficient on its own.
5.21.6 Field constraints #
Each of the five fields is an array of objects matching the appropriate
schema. dependencies and conflicts MUST be present, and MAY be
empty. The other three MAY be omitted, which is equivalent to an empty
array.
Within a single field, entries MUST be sorted lexicographically by
name, and two entries MUST NOT carry identical name values. A
package with several constraints on one target MUST combine them into
that entry's single constraint string.
5.21.7 What satisfies a dependency #
A dependency is satisfied by a candidate package when all of the following hold:
- The candidate's name equals the dependency's
name, or the candidate has aprovidesentry whose name equals it. - If the dependency carries a
constraint, the version satisfies it — the candidate's own version when matched by name, and the matchingprovidesentry's version when matched throughprovides. - The candidate's architecture satisfies the
archqualifier. - The candidate is installed, or is being installed, in the dependency's root (§5.19).
A conflict is triggered by a candidate when the same conditions hold
with respect to a conflicts entry.
A claims field has no effect on satisfaction. A dependency on a role
is satisfied by any installed eligible provider regardless of which one
currently holds the role; claims govern which installed file owns a
contended filesystem name, nothing more (§5.23).
5.22 Derived Capabilities
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Some capabilities are derived mechanically from a package's built
contents rather than declared by hand. So that a producer and a consumer
agree on the name whichever way it arrived, the conventions below are
normative for the capability name.
| Capability | Virtual name | Version |
|---|---|---|
| Shared library | The ELF soname, verbatim — libssl.so.3. | None by default. |
| ELF symbol-version node | elfver(<soname>:<token>), for example elfver(libstdc++.so.6:GLIBCXX_3.4.30). | None: the whole name is an exact capability. |
| pkg-config module | pkgconfig(<module>), where <module> is the .pc file's base name — pkgconfig(glib-2.0). | The .pc file's Version: field, matched as an ordered constraint per §5.7. |
5.22.1 Shared libraries #
A shared-library dependency is the soname listed in a binary's
DT_NEEDED; the corresponding provide is the soname in the providing
library's DT_SONAME.
The soname's ABI-version field is part of the name and is matched by
exact equality: libssl.so.3 is never satisfied by libssl.so.4. A
version MAY be carried on a soname provide when the library's symbol
versions are commensurable with the providing package's own version, as
they are for a C library shipping versioned symbols.
5.22.2 ELF symbol-version nodes #
A strong GNU ELF version need is represented by elfver(<soname>:<token>);
the matching provide comes from that SONAME's non-base version definition.
Both components preserve case and exact bytes. The colon separates components;
it is not permitted within either component. Tokens use ASCII letters, digits,
underscores, dots, plus and minus characters; the complete capability obeys the
normal capability-name length bound. Weak version needs do not impose a mandatory
dependency. A producer must reject unrepresentable metadata rather than omit a
selected version requirement.
These capabilities supplement SONAME identity and are independent of the provider package's version. All required nodes must be present; defining a numerically greater node alone is insufficient. A token from another SONAME does not satisfy the dependency. Producer policy selects the libraries to which this derivation applies; Peios selects libstdc++ to cover GLIBCXX and CXXABI.
5.22.3 pkg-config modules #
A pkg-config dependency is a module named in a .pc file's Requires:
or Requires.private:; the corresponding provide is the .pc file
itself.
5.22.4 Derivation is a producer concern #
Whether a producer derives these automatically is its own business. This section fixes only the names, so that a hand-written entry and a derived entry for the same capability are byte-identical.
5.23 Claim Declarations
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
provides (§5.21) lets several installed packages satisfy one virtual
name. A claim extends that to the filesystem: it lets several
installed packages contend for a single shared filesystem name, with
exactly one owning it at a time. The canonical case is a role daemon —
two registry sources may both be installed, but only one may own
/usr/sbin/registryd.
This section specifies what a package declares. Which provider holds a role, and when the consumer re-evaluates that, is consumer mechanics.
5.23.1 Vocabulary #
- Role — a virtual name (§5.4) that one or more packages contend to
own. A role is identified by the
nameof aprovidesor dependency entry carrying aclaimsfield. - Slot — a named channel within a role. Each slot materialises one filesystem name. A role has one or more slots.
- Claim path — the absolute path a slot materialises at. A slot MAY have more than one.
- Target — the file a claim path points at while a given provider holds the slot. The target is a payload file of the holding package.
- Holder — the single installed package that currently owns a role. A role with no holder is unheld.
5.23.2 The claims field #
A claim is declared by adding a claims field to a dependency entry, an
optional-dependency entry, or a provides entry. It maps a slot name to
a slot descriptor:
"claims":
A slot name MUST conform to the package-name grammar (§5.3).
Which of the two descriptor fields is permitted depends on where the
claims field appears:
- On a dependency or optional-dependency entry — the consumer
side — each slot descriptor MUST contain
pathand MUST NOT containtarget. A consumer declares only where it expects the name; it supplies no implementation. - On a
providesentry — the provider side — each slot descriptor MUST containtargetand MAY containpath. A provider declares the file that answers the slot, and MAY additionally declare a default claim path.
A claims field MUST NOT appear on a conflicts or replaces entry.
Slot keys within a claims object are an unordered JSON object and
carry no ordering requirement. The enclosing arrays remain sorted and
unique by name (§5.21).
Example — one package consumes the role, another provides it:
// the consumer's manifest
"dependencies":
// the provider's manifest
"provides":
5.23.3 Where a target may point #
A target MUST name a path the declaring package itself installs as a
payload entry, and MUST therefore lie within the permitted install
destinations of §5.14. A target that does not correspond to one of the
declaring package's own payload paths is invalid and MUST cause the
package to be rejected.
A consumer MUST verify this itself, against the payload it actually received. Producer-side validation says nothing about a package built elsewhere.
5.23.4 Where a claim path may lie #
A claim path is not a payload entry — it is the location of a consumer-managed link — and is governed by its own rule. It MUST satisfy the payload path-syntax and safety constraints of §5.13, and it MUST lie in one of:
- the permitted install destinations of §5.14;
- under
/run/; or - the well-known root-level name
/init.
Any other location MUST cause the package to be rejected.
/lcl/policy MUST NOT be reachable as a claim path under any
circumstance, by the same rule and for the same reason as §5.14.
5.23.5 Eligibility #
A package is an eligible provider of a role when it has a provides
entry whose name is the role and whose claims field declares a
target for at least one of the role's slots. Only an eligible provider
may hold a role.
A package that depends on a role and declares a claim path for it, but does not provide the role, is a consumer only: it contributes claim paths and can never hold.
5.23.6 What a consumer guarantees #
The materialised links for a role MUST at all times equal the cross-product of the role's computed claim paths with the holder's targets, where the computed claim path set for a slot is the union of:
- every
pathdeclared for that slot by an installed consumer, and - the
pathdeclared for that slot by the holder's ownprovidesentry, if present.
A consumer MUST re-evaluate that set within any transaction that changes its inputs — a change of holder, or the installation or removal of any package declaring a claim path or a target for the role. A claim path declared for an already-held role MUST be materialised retroactively against the current holder; the holder is not re-decided.
A role MAY be held with no materialised links at all, when its computed path set is empty. Holder state is therefore recorded independently of whether any link exists.
A claim link is owned by the consumer, not by any package. It MUST NOT appear in any package's payload and MUST NOT be recorded as a package-owned path. This is what lets two eligible providers coexist: neither ships the contended path, so the one-package-per-path rule (§5.16) is never engaged by the providers themselves.
A claim path MUST NOT collide with a path owned by any installed package, evaluated against the state the containing transaction will produce rather than the state it started from. On collision, materialisation MUST fail and the transaction MUST be rolled back.
A holder swap MUST repoint every one of the role's links within a single transaction, and each repoint MUST be atomic, so that no consumer of a claim path ever observes the path absent.
5.23.7 What claims are not #
- Not a general symlink mechanism. A package needing a fixed symlink among its own files ships a payload symlink entry (§5.17). Claims exist for names contended by several packages.
- Not a service-registration or activation mechanism. A materialised claim link is a symlink and nothing more.
- Not an input to dependency resolution (§5.21).
- Not a way to escape the one-package-per-path rule for ordinary payload files. Only consumer-owned claim links are exempt, and only at paths no package owns.
5.24 Side-Effect Declarations
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Some standard maintenance operations must run after files are installed or removed for a system to function: rebuilding the kernel module dependency cache when modules change, rebuilding the man page index when man pages are added.
These are not install scripts. The format does not permit a package to specify its own install script. A package instead declares which of a closed, enumerated set of maintenance operations it requires, and the consumer invokes them.
5.24.1 Schema #
side_effects is an array of strings:
"side_effects":
Each string MUST be drawn from the set below. An unknown value is invalid and MUST cause the package to be rejected. The array MUST NOT contain duplicates. It MAY be empty, or omitted entirely, for a package requiring no maintenance operation.
5.24.2 The recognised set #
5.24.2.1 depmod #
Rebuilds the kernel module dependency cache for a kernel release.
A package MUST declare depmod if its payload contains kernel module
files (.ko, .ko.*) under /usr/lib/modules/. A package MUST NOT
declare it if its payload contains no kernel modules.
The consumer MUST invoke it once per affected kernel release, naming that release. A package shipping modules for two releases causes two invocations.
5.24.2.2 man-db #
Rebuilds the man page index, so that lookups by keyword are fast.
A package SHOULD declare man-db if its payload contains man pages
under /usr/share/man/.
5.24.3 Semantics #
A side effect MUST be idempotent: running it several times in succession MUST leave the system in the same state as running it once. The recognised set has this property by construction.
A side effect MUST be safe to invoke non-interactively.
A consumer MUST invoke each declared side effect once per
transaction, after every file operation in that transaction is in
place and after the transaction has committed. Side effects MUST be
deduplicated across the packages in a transaction: several packages each
declaring man-db cause one invocation, not several.
A consumer MUST also invoke a side effect when a transaction removes
files whose absence affects that effect's target — removing a kernel
module requires depmod, removing a man page requires man-db —
whether or not any package in the transaction declared it.
5.24.4 Why there is no shared-library cache #
Other systems carry an ldconfig side effect to rebuild
/etc/ld.so.cache. Peios has no such cache and no such side effect, and
this is a property of the layout rather than an omission.
A cache exists to do two things: make lookup fast when the loader must
search many directories, and let it find libraries in directories it
would not otherwise search. Peios has neither problem. The C library is
configured with its library directory, its system library directory and
its runtime-loader directory all set to /usr/lib/<triplet>, and the
loader carries that path compiled in as its default. There is exactly
one shared-library directory, and it is the one the loader already
searches.
So the rule that replaces the declaration is a layout rule, and it is
normative: a package shipping a shared library MUST install it into
/usr/lib/<triplet>. A library installed anywhere else will not be
found, and no maintenance operation exists to make it findable.
Reintroducing a cache would mean reintroducing everything a cache brings with it — a file to keep coherent with the filesystem, a tool in the base to regenerate it, and a failure mode where the two disagree. That trade is only worth making if Peios ever needs more than one library directory.
5.24.5 Ordering #
Side effects are invoked in an implementation-defined order. The recognised set is chosen so that order between distinct effects is not significant, and a consumer MAY invoke them concurrently.
5.24.6 Invocation hardening #
A consumer MUST invoke a side-effect tool with:
- a fixed absolute path to the tool. The set is closed, so the consumer knows each tool's location; it MUST NOT search a path variable.
- a cleared environment containing only well-defined variables. Environment inherited from the invoking context MUST NOT be passed through.
- standard input closed.
A consumer MUST invoke the tool against the installation root the transaction acted on, not against the root the consumer itself is running from.
5.24.7 Failure #
Side effects run after the transaction commits, so a side-effect failure does not — and cannot — roll the transaction back. A consumer MUST report the failure to the operator; the transaction stands.
Because side effects are idempotent, a failed one is self-correcting: re-invoking it, explicitly or as part of the next transaction that declares it, reaches the correct state. A consumer SHOULD make re-invocation straightforward.
5.24.8 Extension #
A future version MAY recognise further identifiers — likely candidates
include update-mime-database, update-desktop-database, and
udev-reload, all excluded here as irrelevant to the scope Peios is
built for. A conforming implementation of this version MUST reject a
manifest declaring any identifier outside the set above.
A future version introducing a new identifier MUST state whether it is order-independent with respect to the existing set. An order-dependent side effect, if one is ever added, MUST be specified with a normative ordering relative to every other recognised effect.
5.24.9 What side effects are not #
- Not a general install-script mechanism. The closed enumeration is what prevents arbitrary code execution at install time.
- Not a way to register a service with the init system. Service integration belongs to the higher-level artifacts that compose packages.
- Not a way to seed registry state.
- Not a way to apply security descriptors, which are applied at file-creation time (§5.20).
A package whose required behaviour cannot be expressed through the manifest is incomplete and cannot be installed through the package format alone. That behaviour MUST be supplied by the higher-level artifact that composes the package.
5.25 The Files Manifest
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A package's integrity is verified at two levels. At the package level, the whole file has a hash and a signature proving it has not been altered since signing. At the per-file level, each payload file has an individual hash proving its content has not been altered between archive creation and installation.
The per-file level lives in the files manifest at .peipkg/files.json.
5.25.1 Schema #
| Field | Description |
|---|---|
schema_version | MUST be 1 in this version. |
algorithm | MUST be sha256 in this version. |
entries | One entry per regular-file payload entry. |
| Entry field | Description |
|---|---|
path | Payload-relative path, identical to the corresponding tar entry path. |
size | Size in bytes of the file's content. |
hash | Lowercase hexadecimal hash of the file's content under the declared algorithm. |
The entries array MUST be sorted lexicographically by path and MUST
NOT contain duplicates.
5.25.2 Coverage #
The files manifest MUST contain exactly one entry per regular-file
payload entry, and MUST NOT contain an entry for a metadata entry
under .peipkg/, a directory entry, or a symlink entry.
A regular-file payload entry with no corresponding files-manifest entry is invalid. A files-manifest entry with no corresponding tar entry is invalid. Either MUST cause the package to be rejected on parse.
Symlinks are integrity-checked through the tar entry's linkname directly, and directories have no content. The files manifest covers only what is verifiable by content hash.
5.25.3 The package hash #
The package hash is the hash of the entire .peipkg file in its
compressed on-wire form, computed with the algorithm declared in the
repository index (§5.33). The required algorithm in this version is
SHA-256.
It is recorded in the repository index, to verify that a downloaded file matches what the repository advertises, and in the signature payload (§5.28), to bind a signature to that exact file.
The package hash is not recorded inside the package: a package cannot contain its own hash.
5.25.4 Algorithm agility #
This version supports SHA-256 only. The algorithm field here and the
hash identifier in the index reserve syntactic space for future
algorithms. A conforming implementation of this version MUST reject any
algorithm value other than sha256.
5.25.5 Two levels, two threats #
The two levels defend against different things, and a consumer MUST verify both.
The package hash plus the signature defends against substitution of the package as a whole. The files manifest defends against corruption or tampering during extraction, after the signature has been verified.
Verifying only the signature leaves extraction errors and on-disk corruption undetectable. Verifying only the per-file hashes leaves the files manifest itself untrusted.
5.26 Verifying a Package
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A consumer MUST perform the following steps, in this order, before installing anything from a package:
- Compute the SHA-256 of the downloaded
.peipkgfile. - Compare it against the hash recorded in the repository index (§5.33). If they differ, the package is corrupted or substituted; abort.
- Verify the inline signature (§5.30). If verification fails, the package's authenticity is unproven; abort.
- Decompress and parse the tar archive, enforcing the layout rules of §5.12, the determinism rules of §5.11 and the detached-signature rules of §5.16.
- Read
.peipkg/manifest.jsonand verify it against §5.18. - Read
.peipkg/files.jsonand verify it against §5.25, including the two-way coverage check and thesize_installedequality of §5.18. - For each payload entry, compute its content hash and compare it against the files manifest. If any file's hash does not match, abort.
- Compare the manifest against the index entry that led here (§5.32). If any field disagrees, abort.
A consumer MUST NOT install any payload before all eight steps complete successfully. Partial installation after a verification failure leaves the system indeterminate and is forbidden.
5.26.1 Ordering is logical, not temporal #
A consumer MAY compute the hashes for steps 1, 3, and 7 in a single streaming pass: feeding the compressed bytes simultaneously through a hasher and a decompressor, piping the decompressed bytes through a second hasher up to the signature entry, and hashing each file's content as the tar walk reaches it.
What the ordering requires is that no payload is committed to its final install path, and no decompressed byte is made visible outside the consumer's own private state, until every step has completed.
5.26.2 Nothing observable before step 3 #
A consumer MUST NOT make any decompressed payload byte visible to another process — including through a staging directory reachable from outside the consumer's own process tree — before signature verification has succeeded.
Streaming decompression and hashing are permitted. Observable filesystem effects are not.
5.26.3 Verifying the whole transaction first #
When a consumer installs several packages together, it MUST complete steps 1 through 8 for every package before extracting any package's payload, and it MUST do so across every installation root the operation touches.
5.26.4 Committing a payload #
A consumer MUST resolve every path component of an install location relative to a verified parent-directory file descriptor, without traversing any symbolic link — including one the consumer itself created earlier in the same operation. A resolution that would traverse a symlink MUST abort the operation.
A pre-existing symlink at an install path MUST be removed atomically before the write, and MUST NOT be followed.
A well-formed package never contains a payload entry whose ancestor component is a symlink. A resolution failure therefore indicates a malformed or hostile package, or hostile filesystem state.
On Linux this is achieved with openat2(..., RESOLVE_NO_SYMLINKS)
anchored at the relevant permitted top-level destination (§5.14), or
with equivalent semantics using O_NOFOLLOW on every component against
a carried directory descriptor. A consumer SHOULD additionally apply
RESOLVE_BENEATH, RESOLVE_NO_XDEV, and RESOLVE_NO_MAGICLINKS as
defence in depth, and SHOULD commit a staged file with renameat2
against the same pinned parent descriptor rather than a re-walked path
string.
5.27 Decompression Bounds
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A consumer MUST bound package decompression, to prevent resource-exhaustion attacks by packages with extreme compression ratios.
Two bounds apply, and both MUST be enforced.
5.27.1 The index-declared bound #
The repository index entry's size_compressed and size_installed
fields (§5.33) bound the legitimate sizes of the compressed and
uncompressed forms. During streaming decompression a consumer MUST
verify that:
- the cumulative compressed bytes consumed do not exceed
size_compressedby more than the lesser of 1% or 16 MiB; and - the cumulative decompressed bytes produced do not exceed
size_installedplus a fixed overhead allowance of 320 MiB.
Both figures MUST be taken from the index entry, not from the package's own manifest. The manifest lives inside the compressed stream and is therefore under the control of whoever produced the bytes being bounded.
The 320 MiB decompressed allowance bounds the structural overhead a conforming package may legitimately carry above its installed payload: tar headers and block padding for up to the §5.A limit of 100,000 entries, plus the metadata files at their maximum sizes. A typical package's overhead is a tiny fraction of it; the allowance is sized so that a consumer never rejects a package conforming to §5.A.
5.27.2 The absolute cap #
Independently of any declared size, a consumer MUST abort decompression when the cumulative decompressed output exceeds an absolute cap. The default cap is 4 GiB. A consumer MAY raise it through operator configuration but MUST NOT raise it silently.
5.27.3 Checked continuously #
Both bounds MUST be checked on every chunk of output, not at end-of-stream.
5.27.4 On exceeding a bound #
Exceeding either bound MUST cause the package to be rejected with no further processing and nothing committed to disk.
5.27.5 Cross-checking the declared size #
The manifest's size_installed and the index entry's size_installed
MUST be equal, and a consumer MUST verify that equality (§5.32).
Together with the files-manifest sum required by §5.18, this makes the
figure a quantity all three of the producer, the repository, and the
consumer can compute independently and agree on.
5.28 Package Signatures
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A package signature binds a package's bytes to a signing key. Verifying it establishes that the package has not been altered since signing, and that the signer held the trusted private key.
5.28.1 The signature entry #
The signature is the final entry in the tar archive, at
.peipkg/signature (§5.12). Its content is a UTF-8 JSON document. Every
tar attribute of the entry — mode, owner, mtime, magic — follows the
determinism rules of §5.11 unmodified, so the entry is mode 0777 like
every other, and §5.16's rationale applies to it identically.
5.28.2 The signed bytes #
The signature is over a SHA-256 hash computed across the concatenation
of every complete tar entry block — header, content, and content-block
padding to the next 512-byte boundary — for every entry preceding
.peipkg/signature, in archive order.
The signed bytes do not include:
- the tar entry header or content of
.peipkg/signatureitself; - the two trailing zero blocks that terminate a tar archive;
- any compression artifact — signing operates on the uncompressed tar bytes.
5.28.3 The envelope #
| Field | Description |
|---|---|
schema_version | MUST be 1 in this version. |
algorithm | MUST be ed25519 in this version. |
key_fingerprint | Fingerprint of the public key (§5.29). Lowercase hex, 64 characters. |
signature | The signature value, base64 per RFC 4648 §4 without padding. For Ed25519 the decoded value is 64 bytes. |
An envelope MUST contain all four fields. A missing field, or an
unrecognised algorithm or schema_version, MUST cause the package to
be rejected.
5.28.4 Strict parsing #
The envelope MUST NOT contain any field beyond the four above, and MUST NOT contain a duplicate key. An implementation of this version parsing an envelope from a future version MUST reject the package with an error naming the schema version mismatch, rather than silently ignoring the unknown fields.
This is a deliberate exception to §5.9's forward-compatibility rule. For security-critical signing data, strict parsing is preferred to permissive ignoring.
5.28.5 Signing procedure #
To sign a package, a producer:
- Constructs every tar entry except
.peipkg/signature. - Serialises them as an uncompressed tar byte stream in archive order.
- Computes the SHA-256 of that stream.
- Signs the resulting 32-byte hash with its Ed25519 private key, per RFC 8032.
- Constructs the envelope with the signature value and key fingerprint.
- Appends the
.peipkg/signatureentry — header, JSON content, and padding — to the tar byte stream. - Compresses the complete stream to produce the
.peipkgfile.
Note that the Ed25519 message is the 32-byte SHA-256 digest, not the tar stream itself. A verifier MUST do the same.
5.28.6 Determinism #
Given identical signed bytes and an identical key, the Ed25519 signature is deterministic per RFC 8032 §5.1.6. A producer that builds the same tar archive and signs it with the same key MUST produce a byte-identical signature entry.
5.28.7 Unsigned packages #
A package without a .peipkg/signature entry is unsigned.
The format permits unsigned packages. A consumer MAY install one if the originating repository's trust policy permits it (§5.37).
An unsigned package MUST conform to every other requirement of this chapter. The manifest, the files manifest, the payload rules, and the integrity rules apply identically to signed and unsigned packages.
5.29 Keys and Fingerprints
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
5.29.1 Algorithm #
Signatures use Ed25519 as defined in RFC 8032. A conforming implementation MUST support Ed25519 signing and verification. Other algorithms are reserved for future versions.
5.29.2 Public key encoding #
A public key is the raw 32-byte Ed25519 public key value of RFC 8032 §5.1.5.
When published as a file, a public key MUST be encoded either as the raw
32 bytes, or as a PEM PUBLIC KEY block per RFC 7468 in the
SubjectPublicKeyInfo form. Tooling MUST accept both.
A published public key file MUST contain only the public key, in one of those two encodings.
5.29.3 Fingerprint #
A public key's fingerprint is the lowercase hexadecimal SHA-256 of the raw 32-byte public key:
fingerprint = lowercase_hex(sha256(public_key_bytes))
The fingerprint is 64 hexadecimal characters. It is computed over the raw key bytes and never over a PEM or SubjectPublicKeyInfo encoding of them.
The fingerprint is the canonical identifier of a public key throughout
this chapter: the signature envelope's key_fingerprint (§5.28) and the
repository descriptor's signing key declarations (§5.31) both use this
form.
A consumer that fetches a public key MUST verify the key's fingerprint against the fingerprint that identified it before admitting it to a trust set.
5.29.4 Key roles #
Two roles are distinguished by usage, not by structure:
- Signing keys are used by a producer to sign packages, descriptors, and indexes.
- Trusted keys are configured into a consumer as keys whose signatures it accepts.
A single key MAY play both roles.
5.29.5 The trust set #
A consumer maintains a trust set: the public keys whose signatures it accepts.
The trust set MUST be partitioned per repository. Each configured repository contributes its declared signing keys to the trust set, scoped to that repository's content.
A signature MUST be accepted only if its key_fingerprint matches a key
in the trust set scoped to the repository the content was fetched
from.
5.29.6 Private keys #
Private key material is not the concern of this specification. Its generation, storage, custody, and rotation are operational matters for the key holder.
Hardware security modules, threshold signing schemes, and air-gapped signing are all compatible with this format, so long as the resulting signature conforms to the envelope of §5.28. This specification cares about the bytes, not how they were produced.
5.30 Verifying a Signature
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
To verify a package's signature, a consumer MUST:
- Decompress the
.peipkgfile to the uncompressed tar bytes. - Walk the tar archive in order, accumulating each entry's complete
blocks — header, content, and content-block padding — until reaching
the entry at path
.peipkg/signature. - Stop at that entry. What has been accumulated is the signed byte range of §5.28.
- Parse the content of
.peipkg/signatureas the signature envelope. - Validate the envelope's
schema_versionandalgorithm. Reject if either is unrecognised, naming the version mismatch where that is the cause. - Look up the public key by
key_fingerprintin the trust set scoped to the originating repository (§5.29). If no matching key is in that trust set, reject. - Determine whether the key is usable for verification given its status (§5.32). Reject a revoked key, and a transitioning key past its validity, before performing any cryptographic operation.
- Compute the SHA-256 of the signed bytes.
- Verify the signature against that hash with the looked-up key, per RFC 8032.
If step 9 succeeds the signature is valid. If it fails, reject the package.
5.30.1 Streaming #
The accumulation in step 2 is conceptual. An implementation MAY hash the signed bytes incrementally as it walks, without retaining the stream; steps 8 and 9 then operate on the running hash state.
Streaming MUST NOT be conflated with early commitment. A consumer hashing incrementally MUST still defer every externally observable filesystem effect until step 9 has succeeded (§5.26).
5.30.2 Locating the end of the signed range #
A verifier computing the signed range by subtracting a fixed header size from a stream offset MUST account for an extended header block preceding the signature entry. §5.11 makes such a header unnecessary for a short-named entry, but a verifier that assumes it away will mis-locate the range for any archive that carries one, and report a signature failure for what is really a framing difference.
5.30.3 Failure conditions #
A consumer MUST reject a package as unverified when any of these holds:
- The package contains no
.peipkg/signatureentry and the trust policy for its originating repository requires signed packages. - The
.peipkg/signatureentry is not the last named entry in the archive. - The envelope does not parse, or carries an unknown or duplicate field.
- The envelope's
schema_versionis not 1. - The envelope's
algorithmis not recognised. - The envelope's
key_fingerprintmatches no key in the trust set scoped to the originating repository. - The matching key's status does not permit verification.
- The cryptographic verification fails.
A rejected package MUST NOT be installed, and the consumer MUST report which condition triggered the rejection.
5.30.4 A package with no originating repository #
A consumer MAY accept a package supplied directly rather than fetched from a configured repository — a file handed to it on the command line. Such a package has no originating repository, and therefore no trust set to verify against.
A consumer that accepts one MUST treat it as unverified: it MUST NOT report the package as signature-verified, and it MUST surface to the operator that the package's authenticity was not established.
5.30.5 What verification proves #
A verified signature establishes integrity — the archive bytes preceding the signature entry have not been altered since signing — and authenticity — the signer held the private key corresponding to a trusted public key at the time of signing.
It does not establish that the signed bytes encode meaningful content: a consumer MUST still validate the manifest, the files manifest, and the per-file integrity (§5.26). It does not establish that the signer intended the package for any particular system. And it does not establish that the content is free of bugs or malice. Signing certifies provenance, not safety.
5.30.6 Replay and substitution #
Signature verification alone does not prevent replay — an attacker substituting an older, validly signed package for a newer one. Defence against substitution comes from the repository index (§5.33), which is itself signed, declares the current authoritative version of each package, and records each package's hash.
A consumer MUST consult the index and verify the package's hash against it before accepting the package, even when the signature verifies.
5.31 The Repository Descriptor
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
A repository descriptor is a small JSON document at a well-known URL describing a repository's identity, its signing keys, and where its indexes live. It is the entry point a consumer fetches when adding or refreshing a repository.
5.31.1 Location #
A repository's descriptor MUST be reachable at <repo-base>/repo.json,
where <repo-base> is the base URL the repository was added under
(§5.36). It MUST be served as static content.
5.31.2 Schema #
| Field | Description |
|---|---|
schema_version | MUST be 1 in this version. |
repo.name | A short identifier for the repository. MUST be non-empty. SHOULD be kebab-case. |
repo.description | OPTIONAL. A human-readable one-line description. |
repo.signing | Signing key information. |
indexes.active | Pointer to the active index (§5.33). |
indexes.archive | Pointer to the archive index (§5.35). REQUIRED. |
The archive pointer is required even when the archive is empty, as it is for a newly established repository. A repository without an archive index is non-conformant.
5.31.3 The signing object #
| Field | Description |
|---|---|
algorithm | MUST be ed25519 in this version. |
keys | One or more keys. MUST contain at least one with status active. |
| Key field | Description |
|---|---|
fingerprint | The key's fingerprint (§5.29): lowercase hex, 64 characters. |
url | Where the public key file is published. MAY be relative to <repo-base>. |
status | One of active, transitioning, revoked (§5.32). |
valid_until | RFC 3339 UTC timestamp after which a transitioning key MUST NOT be accepted. REQUIRED for transitioning; ignored otherwise. |
The keys array MUST be sorted lexicographically by fingerprint. Two
entries with the same fingerprint in one descriptor are invalid.
5.31.4 Index pointers #
| Field | Description |
|---|---|
url | Where the index is published. MAY be relative to <repo-base>. |
signature_url | Where the index's detached signature is published. MAY be relative to <repo-base>. |
The conventional URLs are:
<repo-base>/index/active.json
<repo-base>/index/active.json.sig
<repo-base>/index/archive.json
<repo-base>/index/archive.json.sig
A repository MAY use other URLs by declaring them. The descriptor's URLs are authoritative; the conventional paths are defaults for tooling that has nothing else to go on.
5.31.5 Descriptor signing #
The descriptor MUST be accompanied by a detached signature published at
<repo-base>/repo.json.sig. The detached signature is a signature
envelope (§5.28) over the SHA-256 digest of the descriptor file's exact
bytes — the same construction as a package signature.
The signing key MUST be one of the keys listed in the descriptor's own
repo.signing.keys, with status active or transitioning.
A repository configured to permit unsigned content MAY publish an unsigned descriptor and unsigned indexes. This is a security weakening opted into per repository, and a consumer MUST NOT treat the absence of a signature as a fetch failure for such a repository.
5.31.6 Canonical form #
The descriptor SHOULD be canonically formatted so that signing is reproducible: fields in the schema's order, key arrays sorted as specified, no trailing whitespace, a single trailing newline.
5.31.7 Naming #
A consumer MAY refer to a repository by a local handle of its own
choosing. When it does, it MUST NOT require that handle to equal
repo.name, and MUST NOT compare an index's repo field against the
local handle. An index's repo field is compared against the
descriptor's repo.name.
5.32 Signing Key Status and Rotation
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
5.32.1 Statuses #
A key's status describes its role in the repository's current
operation.
| Status | Used for new signatures | Accepted for verification |
|---|---|---|
active | yes | yes |
transitioning | no | yes, until valid_until |
revoked | no | never, whatever the cryptography says |
active— the key currently signs new packages and indexes. A consumer MUST accept its signatures.transitioning— the key was active and remains acceptable for verification until itsvalid_untiltimestamp, but no longer produces new signatures. A consumer MUST accept its signatures while the current time is at or beforevalid_until, and MUST reject them afterwards. Atransitioningkey entry MUST carry avalid_until.revoked— the key is no longer trusted under any circumstance. A consumer MUST reject its signatures regardless of when they were produced and regardless of whether they verify cryptographically.
A status other than these three is invalid.
A repository MAY have several active keys, permitting parallel
signing; any number of transitioning keys, each with its own
valid_until; and any number of revoked keys.
revoked is the explicit signal of a compromise event. transitioning
is for routine rotation only, and the two MUST NOT be conflated.
5.32.2 Retention of revoked entries #
A revoked entry MUST be retained in the descriptor for at least one year after the revocation. Removing it prematurely would hide the public acknowledgement of compromise from consumers with stale caches.
A repository MUST continue to serve the public key file of a revoked key for as long as its entry is retained, so that a consumer fetching the descriptor can resolve every key it declares.
5.32.3 Rotation #
A repository rotates a signing key by:
- Generating a new key pair.
- Adding the new public key to the descriptor's key list alongside the existing one.
- Beginning to sign new content with the new key.
- After a transition period during which both are advertised, marking
the old key
transitioningwith avalid_until, and eventually removing it.
During the transition, content signed with either key is acceptable. After the old key's validity lapses, only the new key's signatures remain acceptable.
The length of the transition period is operational policy and is not specified here. Its purpose is to give consumers time to fetch the updated descriptor and learn the new key before old signatures stop being honoured.
5.32.4 The offline emergency key #
A repository SHOULD maintain at least one offline active signing key in addition to its routine signing keys. The offline key's private material is stored separately from build infrastructure and is used only for descriptor updates and emergency rotations.
The offline key exists to break a chicken-and-egg in compromise response. Revoking a compromised signing key requires publishing a new descriptor, which must itself be signed. If the only trusted key is the compromised one, the operator must sign the revocation with the compromised key — giving an attacker who holds that same key the ability to substitute their own revocation that adds a key of their choosing.
With an offline key, the operator signs the descriptor update revoking the compromised key without relying on the compromised key at all. Consumers holding the offline key in their trust set accept the update; consumers who do not must perform an out-of-band trust-anchor refresh (§5.37).
5.32.5 Compromise #
A key SHOULD be considered compromised if its private material may have been obtained by an unauthorised party.
A compromised key MUST be marked revoked in the descriptor immediately
on discovery. Packages signed with it SHOULD be re-signed with a fresh
key and re-published at new revisions.
The revoked status is the in-band revocation channel this
specification defines. It defends at descriptor-update granularity: a
consumer that successfully refreshes learns of the revocation at once,
and a consumer caching an older descriptor retains trust in the revoked
key only until it re-syncs — a window bounded by the maximum trusted age
of §5.37.
5.33 The Active Index
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
The active index lists the current version of every package a repository advertises. It is the index a consumer fetches on a routine sync.
5.33.1 Location and signing #
The active index's URL and its detached signature's URL are declared by the descriptor (§5.31).
The index MUST be accompanied by a detached signature: a signature
envelope (§5.28) over the SHA-256 digest of the index file's exact
bytes. The signing key MUST be one of the descriptor's keys with status
active or transitioning.
A repository configured to permit unsigned content MAY publish the active index unsigned.
5.33.2 Schema #
| Field | Description |
|---|---|
schema_version | MUST be 1 in this version. |
repo | The repository's name, matching repo.name in the descriptor. |
kind | MUST be active. |
index_version | A monotonically increasing positive integer identifying this index revision (§5.34). |
generated_at | RFC 3339 UTC timestamp of generation. |
packages | One entry per package currently advertised. |
A consumer MUST verify that repo matches the descriptor's repo.name
and that kind matches the index it requested. An archive index served
in place of an active one MUST be rejected.
5.33.3 Package entries #
An entry MUST contain name, version, architecture,
dependencies, conflicts, provides, replaces, side_effects,
size_compressed, size_installed, hash, and url. The array fields
MUST be present even when empty, emitted as []. The remaining fields
are RECOMMENDED and MAY be omitted.
name, version, and architecture MUST each be validated against
§5.3, §5.5, and §5.8 respectively on parse — with the same strictness a
manifest receives. An index is fetched from the network and its values
flow into URL construction and into the consumer's own records.
size_compressed and size_installed are required because they are the
input to the decompression bound of §5.27.
license_class, when present, MUST be validated against the closed set
of §5.18 on parse; absent means unknown, as in the manifest. It is
carried so a consumer can apply a licence policy at planning time,
before fetching anything.
alternate_upgrade, when present, MUST be validated exactly as the
manifest's (§5.18). It is carried so a consumer can refuse or hold back
a package (§5.18 rules 1 and 2) at planning time, before fetching
anything, and so an every-package upgrade can report what it held back
without downloading it.
hash carries algorithm, which MUST be sha256 in this version, and
value, the lowercase hexadecimal SHA-256 of the .peipkg file in its
compressed on-wire form.
5.33.4 The derivation rule #
The active index is a derived view of the packages it advertises.
Every field of an entry MUST exactly match the corresponding field of
that package's manifest where one exists, and MUST exactly match the
properties of the actual package file for hash, size_compressed, and
url.
Tooling generating an index MUST extract values directly from package manifests. Editing an index by hand is forbidden.
Where a manifest contradicts an index entry, the manifest is authoritative (§5.18) — and the contradiction is a defect in the repository, not a difference to accommodate. A consumer MUST compare the downloaded package's manifest against the index entry that led to it, across every field the index carries, and MUST reject the package on any mismatch (§5.26 step 8).
5.33.5 Deliberate omissions #
The index omits three manifest fields:
sd_overrides— not relevant to planning, and potentially large.build.source_ref— long and low in information density; consult the package when it is wanted.- the manifest's own
schema_version— the index carries its own.
These remain in the manifest and are available to a consumer that fetches the package. Because they are omitted rather than mismatched, they are outside the comparison above.
5.33.6 URLs #
url declares where the package file is fetched from, and MAY be
relative or absolute (§5.36). The conventional form is relative:
"url": "/p/nginx/1.26.2-3/nginx_1.26.2-3_x86_64.peipkg"
This keeps an index portable: the same file is valid at any
<repo-base> hosting the same package files.
5.33.7 Ordering #
The packages array MUST be sorted lexicographically by name. Two
entries with the same name in an active index are invalid: each name
appears exactly once.
5.33.8 Unknown fields #
A consumer MUST ignore unknown fields, at the top level and per package, per §5.9. A producer MAY emit additional fields in a future schema version.
The exception is a field whose meaning is critical to correctness, such
as a hash algorithm identifier. Such changes are expected to arrive
through a schema_version bump, not as a silent addition.
5.33.9 Size and caching #
For a repository of a few hundred packages the active index is on the order of 100 KB compressed. A consumer SHOULD fetch with HTTP-level compression where it is offered, and SHOULD cache the parsed index between invocations: the index changes only when the repository publishes, which is far less often than a consumer reads.
A cached index MUST be stored under a security descriptor granting write access only to the principal permitted to install packages.
A consumer MUST re-verify a cached index's signature on every operation that relies on it, rather than trusting its cached state across operations. Caching avoids re-parsing; it does not avoid re-verifying.
A consumer SHOULD additionally cross-check a cached index against its
own recorded freshness state (§5.34), and reject a cached index whose
index_version or generated_at disagrees with what it recorded.
5.34 Freshness and Rollback Protection
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
An index that verifies is not necessarily current. This section defends against rollback — replaying an older signed index to hide newer packages — and freeze — holding a consumer at a current-but-stale index while its clock runs on.
Every requirement here applies to both the active index and the archive index. An attacker who can replay one can replay the other, and the archive index is the candidate source for every downgrade and pin.
5.34.1 Monotonic index versions #
Each publication of an index MUST set index_version to a value
strictly greater than any previously published value for the same
repository.
A consumer MUST record, per repository, the highest index_version it
has ever observed. On each fetch it MUST reject an index whose
index_version is less than that recorded value, even when the index
is correctly signed by a still-trusted key.
A consumer MUST also record the generated_at of the last index it
trusted, and MUST reject an index whose generated_at is older than the
recorded value.
5.34.2 No progress is a failed fetch #
A fetch returning an index whose index_version and generated_at
both equal the recorded values is a failed refresh, not a successful
one. A consumer MUST NOT advance its "last successful refresh" timestamp
on such a fetch.
5.34.3 The initial floor #
Adding a repository bootstraps the consumer's recorded floor. To defend
against an attacker serving a stale-but-signed index at that moment, a
repository SHOULD distribute a minimum acceptable index_version
alongside its trust anchors, through the same out-of-band channel. A
consumer SHOULD use that minimum as its initial floor, and MUST refuse
the add when the first index fetched falls below it.
A consumer MUST NOT reset a recorded floor as a side effect of any operation other than removing the repository. In particular, re-adding an already-configured repository MUST NOT lower the floor: the operation either applies the recorded floor as a refresh would, or is refused.
5.34.4 Maximum index staleness #
A consumer MUST enforce a maximum staleness window on the index itself,
measured from its generated_at. An index older than 90 days MUST
trigger a refresh attempt before any install operation proceeds.
The 90-day default MAY be tuned by operator configuration; a value greater than 365 days SHOULD generate a warning each time it is exercised.
5.34.5 What these checks buy #
Per-package signing and index signing both still verify under a rollback: the attacker is replaying genuine, correctly signed content. What changes is the set of packages the consumer believes is current. The monotonic version check is what makes that set unable to move backwards, and the no-progress rule is what stops it from being frozen in place.
5.35 The Archive Index
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
The archive index lists every version of every package a repository has ever advertised, including versions superseded by newer releases. It is the source of historical data for downgrade, version pinning, and forensic queries.
5.35.1 Retention #
A repository MUST retain every package version it has ever advertised. Once a package has been published at version V, the repository MUST continue to make V fetchable indefinitely, and the archive index MUST continue to list it.
A repository MAY retire pre-release or development versions under a stated retention policy. Retirement MUST NOT silently remove a package a consumer might be using, and SHOULD be coordinated with consumer notice.
A pruned package MUST also be removed from the repository's package storage: the archive index MUST NOT reference a package file that is no longer fetchable.
5.35.2 Location and signing #
The archive index's URL and its detached signature's URL are declared by the descriptor (§5.31). It MUST be signed under the same rules as the active index (§5.33).
5.35.3 Schema #
The top-level schema is identical to the active index (§5.33), except:
kindMUST bearchive;- the
packagesarray MAY contain several entries with the samename, at different versions.
The per-package entry schema is identical. Each historical version contributes one entry.
index_version semantics are identical, and every freshness and
rollback requirement of §5.34 applies to the archive index exactly as it
does to the active one.
5.35.4 Ordering #
The packages array MUST be sorted lexicographically first by name,
then within a name by version descending per §5.6. The first entry
for any name is its highest version; subsequent entries for that name
are progressively older.
Where two entries of one name share a version — differing only in architecture — the ordering between them MUST be total and MUST be stated by the producer's tooling, so that the file is reproducible.
5.35.5 Relationship to the active index #
For every entry in the active index there MUST be at least one entry in
the archive index with the same name, version, architecture, and
hash. The archive index is a superset of the active index.
Equivalently: the active index is the per-name maximum projection of the archive index, where "maximum" is the highest version per name under §5.6.
A repository publishing both indexes SHOULD publish them at the same
index_version and generated_at, so that a consumer holding one has a
usable floor for the other.
5.35.6 Fetch frequency #
The archive index is large compared to the active index — potentially many megabytes for a long-running repository. A consumer SHOULD fetch it only when it is needed: for a historical query, for a pin or a downgrade, or when its cached copy expires. A routine sync SHOULD fetch only the active index.
A consumer SHOULD cache the archive index aggressively, since it changes only when a version is published or pruned.
5.36 URL Conventions
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Every URL in this chapter maps to a static file. The protocol requires no server-side computation, no dynamic response, and no content negotiation beyond optional HTTP-level compression.
5.36.1 The repository base #
A repository is identified by a base URL, <repo-base>.
The base URL MUST be a syntactically valid HTTP or HTTPS URL per RFC 3986, and MUST NOT have a trailing slash: the well-known relative paths below are appended directly.
HTTPS MUST be used, unless the consumer has been configured with an explicit per-repository insecure-transport allowance. There is no global form of that allowance, and its use MUST generate a per-operation warning.
Enabling the allowance on a repository that has already been added MUST require explicit operator authorisation and MUST emit an audit event. Setting it as part of the initial add is covered by the operator's trust decision at that moment and requires no separate event beyond the add's own.
A consumer MAY additionally support a file:// base URL for local
development. A file:// repository MUST be subject to the same
per-repository allowance as an HTTP one: it is not HTTPS, and admitting
it silently makes removable or network-mounted media a trusted source
without the operator ever acknowledging it.
5.36.2 Conventional paths #
| Path | Content |
|---|---|
<repo-base>/repo.json | Repository descriptor (§5.31) |
<repo-base>/repo.json.sig | Detached signature on the descriptor |
<repo-base>/index/active.json | Active index (§5.33) |
<repo-base>/index/active.json.sig | Detached signature on the active index |
<repo-base>/index/archive.json | Archive index (§5.35) |
<repo-base>/index/archive.json.sig | Detached signature on the archive index |
<repo-base>/keys/<fingerprint>.pub | Public key file, named by full fingerprint |
<repo-base>/p/<name>/<version>/<filename> | Package file |
A repository SHOULD use these paths unless it has a reason not to; when
it does not, the descriptor declares the ones it uses. A consumer that
knows only <repo-base> MUST be able to locate repo.json at the
conventional path. The descriptor carries the URLs for everything else.
5.36.3 Package URLs #
<repo-base>/p/<name>/<version>/<filename>
where <name> conforms to §5.3, <version> is the full version string
of §5.5, and <filename> is <name>_<version>_<architecture>.peipkg.
https://pkgs.peios.org/p/nginx/1.26.2-3/nginx_1.26.2-3_x86_64.peipkg
5.36.4 Sibling artifacts #
The directory containing a package file MAY hold additional siblings for that version. These are reserved for future use and are not normative here:
<repo-base>/p/<name>/<version>/<filename>.debug.peipkg
<repo-base>/p/<name>/<version>/<filename>.sbom.json
<repo-base>/p/<name>/<version>/<filename>.attestation.json
A consumer conforming to this version MUST NOT attempt to fetch a sibling artifact. A producer MAY publish them; their meaning is defined by a future version.
5.36.5 Relative URLs #
A URL field in a descriptor or an index MAY be absolute or relative.
- An absolute URL, carrying a scheme, is used as-is.
- A URL beginning with
/is resolved against<repo-base>by prepending the base. - A URL with neither a scheme nor a leading
/is resolved against the URL of the document containing the reference, per RFC 3986 §5.
5.36.6 Hosting #
A conformant repository may be hosted on a plain HTTP server, an object store with an HTTP frontend, a static site host, a CDN in front of any of those, or a combination — descriptor and indexes on a static host, package files on object storage behind redirects.
5.36.7 Network failure #
A consumer that fails to fetch a URL MUST NOT silently fall back to outdated cached data. Using a stale cache without explicit operator consent can mask substituted content or a revoked-key update.
A consumer SHOULD offer a way to configure cache-staleness tolerance per repository.
A consumer whose cached index for a configured repository fails to load or verify MUST treat that as a failure of the operation rather than proceeding without that repository.
5.37 Establishing Trust in a Repository
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Trust is configured per repository, never globally. Each repository a consumer is configured with has its own trusted signing keys (§5.29), its own signature policy, and its own priority.
5.37.1 Adding a repository #
To add a repository, the operator supplies its <repo-base> URL, one or
more expected key fingerprints — the trust anchors — and a signature
policy.
The consumer then:
- Fetches
<repo-base>/repo.jsonand<repo-base>/repo.json.sig. - Fetches the public key for each supplied anchor fingerprint, from the conventional URL or from the URL the descriptor declares, and verifies each fetched key against the fingerprint that named it (§5.29).
- Verifies the descriptor's signature against those anchor keys and only those.
- On success, records the descriptor's contents — including every signing key and status — as the repository's initial trust state.
- On failure, rejects the add and reports why.
A consumer MUST NOT add a repository whose signing key it learned from the repository itself without prior verification against an anchor. Trust anchors are obtained out of band: through a project's website, its documentation, or the operating system image.
5.37.2 Guarding against a mistyped fingerprint #
When presenting a fetched key for confirmation, a consumer MUST:
- display the 64-character fingerprint in groups separated by spaces or
colons — conventionally four characters per group, as
1a2b 3c4d 5e6f ...; - display the fetched key's fingerprint alongside the one the operator supplied, for visual comparison, before recording any trust state;
- require explicit confirmation before recording. Automatic confirmation on the basis of a bit-for-bit match is permitted only in a non-interactive context where the operator pre-supplied the fingerprint through a configured channel.
When a repository declares several active keys, the operator is
RECOMMENDED to supply anchors for at least two of them, as
defence-in-depth against a single mistyped anchor.
A consumer MUST report an anchor mismatch by naming both the anchor the operator supplied and the fingerprints the descriptor actually declares. A mismatch is most often a transcription error, and that is precisely the diagnostic needed to find one.
5.37.3 Signature policy #
| Policy | Meaning |
|---|---|
required | Every package and index MUST be signed and verify. Unsigned content from this repository is rejected. |
optional | Signed content is verified. Unsigned content is accepted with a per-operation warning. |
These are the only two policies. There is no silently-accept-unsigned
policy: a consumer intentionally permitting unsigned content does so
through optional, which always warns.
The warning MUST surface on every install, upgrade, and refresh that accepts unsigned content — not once per session — so that a misconfigured trust state stays continuously visible.
A consumer's default policy for a newly added repository SHOULD be
required unless the operator explicitly chooses otherwise, and the
official repository SHOULD be configured required.
optional means signed content is verified. A consumer MUST NOT
treat the absence of trust anchors as licence to stop verifying: a
repository under optional that publishes signatures MUST have them
verified, and one that publishes none MUST produce the warning rather
than a fetch error.
5.37.4 Refresh #
A consumer SHOULD refresh its cached repository state periodically. A refresh MUST:
- Fetch the current descriptor and its signature.
- Verify the signature against any key whose status was
activeortransitioningin the previously trusted descriptor. - On success, record the new descriptor as the current trust state, replacing the previous key set with the new one.
- Fetch the active index and verify it against the new descriptor's keys, applying §5.34.
- Optionally fetch and verify the archive index, applying §5.34 to it as well.
A failed refresh MUST leave the previous trust state in place and be reported. A consumer MUST NOT fall back to unverified state.
5.37.5 Maximum trusted age #
A consumer MUST track the time of the last successful refresh per repository. When that exceeds the maximum trusted age, the consumer MUST attempt a refresh before any install, upgrade, or downgrade against that repository. If the attempt fails, the consumer MUST report the failure and refuse the operation, unless the operator explicitly authorises proceeding on stale trust state.
The default maximum trusted age is 30 days. It MAY be tuned by operator configuration; a value above 180 days SHOULD produce a per-operation warning, so that a configuration effectively disabling the check stays visible.
5.37.6 Priority #
A consumer MAY configure several repositories. Each has a numeric priority: a positive integer, where a lower number is a higher priority.
A consumer's default assignment SHOULD give the official repository the lowest number. Other repositories receive priorities at the operator's discretion.
5.37.7 Removal #
A consumer MAY remove a configured repository at any time. Removal deletes the cached state and the trust set scoped to that repository. It does not uninstall packages already installed from it; those remain installed, and their origin is retained.
Re-adding a removed repository performs the full trust ceremony afresh; previous state is not implicitly restored.
5.37.8 Orphaned packages #
A package whose originating repository has been removed or revoked is orphaned: its trust chain is no longer verifiable by the current trust state. A consumer MUST:
- display an orphaned package with a clear indicator in query output;
- surface the orphan state on any operation involving it, and recommend an audit before proceeding;
- refuse an upgrade to an orphaned package unless a currently trusted repository now claims it by name.
A consumer MUST NOT treat an unknown origin as an absent origin. Wherever this chapter gates an operation on the relative priority of two repositories, an orphaned package's origin MUST be treated as at least as trusted as any configured repository, so that the gate still fires.
Operators meeting an orphaned package SHOULD audit it: verify the installed files' hashes against trustworthy out-of-band records, and consider reinstalling or removing it through a trusted repository.
5.37.9 Between repositories #
When two configured repositories publish a package of the same name, no
conflict exists at the format level; the consumer resolves which to
install by priority. The same applies to overlapping provides or
replaces relations: the higher-priority repository's claim wins.
An operator publishing a provides that shadows a package of the
official repository SHOULD document it clearly, and a consumer SHOULD
warn when a lower-priority repository's provides shadows a
higher-priority package.
Two guards require explicit operator confirmation, and neither may be satisfied by a general "proceed" affirmation:
- Applying a
replacesdeclared by a lower-priority repository against a package originally installed from a higher-priority one. A repository silently replacing a more-trusted package is a real escalation path, and confirmation stops it happening as a side effect of a routine upgrade. - Applying a
conflictsdeclared by a lower-priority repository that would cause the cascade-removal of a package from a higher-priority one. That is a denial-of-availability vector, and confirmation stops a low-trust install from silently uninstalling a high-trust package.
A consumer that resolves a conflict by rejecting the plan outright, rather than by cascading removals, satisfies the second guard vacuously.
5.37.10 Compromise response #
If a repository's signing key is suspected of compromise, a consumer SHOULD disable the repository immediately, audit the packages installed from it for tampering, and, once the operator has published a new descriptor with the compromised key removed, perform a fresh trust-add with new anchors.
This version defines no automated revocation mechanism beyond the
revoked key status (§5.32). Compromise response is operational.
5.38 Extension
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Every document in this chapter carries a schema_version, currently 1.
A change that a conforming implementation of this version cannot process
correctly requires a version bump; a change it can safely ignore does
not.
5.38.1 Additive changes #
The following are additive and do not require a version bump:
- A new optional field in the manifest, an index entry, or the repository descriptor. A consumer ignores it (§5.9).
- A new optional metadata entry under
.peipkg/(§5.12). A consumer ignores it, and its presence MUST NOT prevent installation. - A new sibling artifact alongside a package file (§5.36).
A producer emitting an additive extension MUST ensure that a consumer ignoring it still behaves correctly. An extension whose omission changes what gets installed is not additive.
5.38.2 Changes requiring a version bump #
- Adding, removing, or changing the meaning of a required field.
- Adding a value to a closed enumeration: the side-effect identifiers of §5.24, the architecture identifiers of §5.8, the hash algorithms of §5.25, the signature algorithms of §5.28, the key statuses of §5.32, the index kinds, the constraint operators of §5.7, the licence classes of §5.18, or the signature policies of §5.37. A conforming implementation of this version MUST reject a value outside each of those sets, so a new value is not ignorable.
- Any change to the version comparison algorithm of §5.6, which is frozen.
- Any change to the determinism rules of §5.11, which decide the bytes.
- Any change to the signature envelope of §5.28, which is strictly parsed by construction.
5.38.3 Reserved space #
This version reserves syntactic room in three places, so that a future extension can be additive where it would otherwise not be:
- The
algorithmfields of the files manifest and the index hash object reserve room for a further hash algorithm. - The
archqualifier on a dependency reserves room for explicit architecture identifiers, for a multi-architecture system. - The sibling-artifact paths of §5.36 reserve room for build attestations and bills of material.
An implementation of this version MUST reject a value in a reserved space rather than guess at it.
5.38.4 Deprecation #
A field this specification requires MUST NOT be removed within a
schema_version. When a field becomes unnecessary, a producer continues
emitting it and a future version removes it under a new
schema_version.
5.39 Conformance
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
5.39.1 Producer #
A conforming producer:
- emits packages satisfying §5.10 through §5.17: the container, every determinism rule, the internal layout, the payload path constraints, the install destinations, the triplet rule, the entry rules, and the symlink rules;
- emits a manifest satisfying §5.18, with names, versions, and architectures satisfying §5.3 through §5.8;
- emits a files manifest satisfying §5.25, covering exactly the
regular-file payload entries, with
size_installedequal to the sum of its sizes; - declares relationships satisfying §5.21, sorted and unique within each field, using the derived-capability names of §5.22 where a capability is machine-derived;
- declares claims satisfying §5.23, with every target a payload path of its own;
- declares side effects satisfying §5.24, declaring each that its payload requires and none that it does not;
- signs packages satisfying §5.28, or emits them unsigned knowing they will be accepted only under a permissive policy.
5.39.2 Repository #
A conforming repository:
- publishes a descriptor satisfying §5.31 with a valid detached signature, and serves the public key file of every key the descriptor declares, including revoked ones (§5.32);
- publishes an active index satisfying §5.33 and an archive index satisfying §5.35, each with a valid detached signature, each derived directly from package manifests;
- increases
index_versionstrictly on every publication (§5.34); - retains every version it has ever advertised, and removes a pruned package from both its archive index and its storage (§5.35);
- serves everything over HTTPS at the URLs its descriptor declares (§5.36).
5.39.3 Consumer #
A conforming consumer:
- verifies a package by §5.26 in full, including the transaction-wide rule and the path-resolution rule, before installing anything;
- enforces the decompression bounds of §5.27 continuously, from the index-declared sizes;
- verifies signatures by §5.30, against a trust set scoped to the originating repository, honouring key status before any cryptography;
- rejects a package violating any rule of §5.10 through §5.25 — the determinism rules and the payload rules included, on the way in, not only on the way out;
- enforces the freshness and rollback rules of §5.34 on both indexes, and never lowers a recorded floor except by removing the repository;
- establishes and maintains trust by §5.37, including the fingerprint comparison, the per-operation warnings, the maximum trusted age, the orphan rules, and the two cross-repository guards;
- enforces the security descriptor policy of §5.20;
- invokes side effects by §5.24, once per transaction, by fixed absolute path, with a cleared environment, against the root the transaction acted on;
- materialises claims by §5.23, and never at a path an installed package owns.
5.39.4 What conformance does not require #
A conforming consumer is not required to resolve dependencies by any particular algorithm, to store its state in any particular form, to recover from an interrupted operation by any particular mechanism, or to offer any particular command surface. Those are its own design, and §5.1 places them outside this chapter deliberately.
What it is required to do is reach the same answer as any other conforming consumer about whether a given package satisfies a given dependency (§5.21), and about which of two versions is newer (§5.6). Those two questions are the ones a producer's declarations depend on, and they are frozen.
Appendix 5.A Limits and Defaults
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Every limit below is a minimum conformance figure: a consumer MUST process a package or document whose characteristics fall within it, and MUST reject one that exceeds it.
A consumer MAY raise a limit through operator configuration, but MUST NOT raise one silently: an operator-tuned value SHOULD be logged and surfaced in diagnostic output.
A producer SHOULD stay well below these figures. They exist to bound a consumer's resource use when processing a maliciously crafted package, not to describe the scale of a well-formed one.
5.A.1 Package structure #
| Limit | Maximum |
|---|---|
| Payload entries | 100,000 |
.peipkg/manifest.json size | 16 MiB |
.peipkg/files.json size | 64 MiB |
.peipkg/signature size | 64 KiB |
| Single payload path component (UTF-8 bytes) | 255 |
| Complete payload path (UTF-8 bytes) | 4096 |
| Path nesting depth (components) | 256 |
| Single claim path (UTF-8 bytes) | 4096 |
5.A.2 Manifest arrays #
| Limit | Maximum |
|---|---|
dependencies | 10,000 |
optional_dependencies | 10,000 |
conflicts | 10,000 |
provides | 10,000 |
replaces | 1,000 |
sd_overrides | 100,000 |
Single sd_override decoded sd length | 64 KiB |
Slots per claims field | 64 |
| Claim paths materialised per role | 256 |
The claim-path figure is a materialisation limit, not a manifest limit: it bounds the union computed across every installed package declaring a path for that role, which is the quantity an adversary controls by installing many consumer-only packages.
5.A.3 Identity #
| Limit | Value |
|---|---|
| Package name length | 2 to 64 characters |
| Virtual name length | 2 to 128 characters |
| Architecture identifier length | at most 16 characters |
5.A.4 Documents #
| Limit | Value |
|---|---|
| JSON nesting depth | 64 |
| Integer field range | unsigned 64-bit |
5.A.5 Decompression #
| Bound | Value |
|---|---|
Compressed overrun allowance over size_compressed | the lesser of 1% or 16 MiB |
Decompressed overhead allowance over size_installed | 320 MiB |
| Absolute decompressed cap | 4 GiB (default; operator-tunable) |
5.A.6 Repository defaults #
| Default | Value |
|---|---|
| Maximum trusted age | 30 days |
| Maximum trusted age producing a warning | above 180 days |
| Maximum index staleness | 90 days |
| Maximum index staleness producing a warning | above 365 days |
| Revoked key retention | at least 1 year |
| Repository priority | positive integer; lower is higher priority |
| Default signature policy for a new repository | required |
Appendix 5.B Enumerated Values
Peios / Peios technical reference / PSPU / Package Format and Repository Protocol
Every set below is closed in this version. A conforming
implementation MUST reject a value outside it, and a new value requires
a schema_version bump (§5.38).
5.B.1 Architecture identifiers #
| Identifier | Triplet | Notes |
|---|---|---|
x86_64 | x86_64-linux-peios | primary target |
aarch64 | aarch64-linux-peios | secondary target |
noarch | none | architecture-independent |
Defined in §5.8.
5.B.2 Pre-release rank tokens #
| Token | Rank |
|---|---|
dev | 0 |
alpha | 1 |
a | 1 |
beta | 2 |
b | 2 |
pre | 3 |
rc | 4 |
| any other alphabetic segment | 5 |
Rank 0 sorts lowest. Rank-5 tokens compare lexically against each other. Recognition is case-insensitive. Defined in §5.6.
5.B.3 Constraint operators #
| Operator | Meaning |
|---|---|
= | exactly equal |
> | strictly greater than |
>= | greater than or equal |
< | strictly less than |
<= | less than or equal |
!= | not equal |
A bare version with no operator means =. Comma is the AND separator.
Defined in §5.7.
5.B.4 Side-effect identifiers #
| Identifier | Declared when | Invoked as |
|---|---|---|
depmod | the payload contains kernel modules (MUST) | once per affected kernel release, naming it |
man-db | the payload contains man pages (SHOULD) | the tool, in quiet mode |
Defined in §5.24.
5.B.5 Licence classes #
| Class | Meaning |
|---|---|
unknown | Nothing asserted. The meaning of an absent license_class. |
free | Free and open-source software. |
firmware | Redistributable device firmware: no source, executes on a device rather than the CPU, needed to use hardware the user owns. |
proprietary | Non-free software that runs on the CPU. |
Defined in §5.18.
5.B.6 Hash algorithms #
| Algorithm | Identifier | Status |
|---|---|---|
| SHA-256 | sha256 | REQUIRED; the only valid value |
| BLAKE3 | blake3 | RESERVED for a future version |
Defined in §5.25.
5.B.7 Signature algorithms #
| Algorithm | Identifier | Status |
|---|---|---|
| Ed25519 | ed25519 | REQUIRED; the only valid value |
Defined in §5.29.
5.B.8 Signing key statuses #
| Status | Signs new content | Accepted for verification |
|---|---|---|
active | yes | yes |
transitioning | no | until valid_until |
revoked | no | never, regardless of cryptographic validity |
Defined in §5.32.
5.B.9 Index kinds #
| Kind | Content |
|---|---|
active | the current version of each package |
archive | every version ever shipped |
Defined in §5.33 and §5.35.
5.B.10 Signature policies #
| Policy | Unsigned content |
|---|---|
required | rejected |
optional | accepted with a per-operation warning |
There is no silently-accept-unsigned policy. Defined in §5.37.
5.B.11 Reserved metadata paths #
| Path | Required |
|---|---|
.peipkg/manifest.json | yes |
.peipkg/files.json | yes |
.peipkg/signature | in every signed package |
The .peipkg/ prefix is reserved; a payload entry MUST NOT use it.
Defined in §5.12.
5.B.12 Permitted entry types #
| Type | Typeflag |
|---|---|
| Regular file | 0 or \0 |
| Directory | 5 |
| Symbolic link | 2 |
Every other type MUST cause the package to be rejected. Defined in §5.12.
5.B.13 Permitted top-level install destinations #
/usr/bin/, /usr/sbin/, /usr/lib/<triplet>/, /usr/lib/debug/,
/usr/lib/modules/<release>/, /usr/lib/firmware/,
/usr/lib/os-release, /usr/libexec/, /usr/share/, /usr/include/,
/usr/src/debug/, /usr/src/dist/, /usr/etc/, /usr/conf/, /var/,
/boot/, /hooks/, /++/.
A payload entry MUST NOT install under any other top-level path, unless
the package declares itself a special system package and the
operator has separately opted in. /lcl/policy is unreachable under
every circumstance. Defined in §5.14.
5.B.14 Permitted claim path locations #
The destinations above, plus /run/ and the well-known root-level name
/init. Defined in §5.23.
6.1 Scope and Roles
Peios / Peios technical reference / PSPU / Name Resolution Interface
This chapter specifies the Name Resolution Interface (NRI): the protocol by which a program asks the machine's resolver for the records behind a name, the protocol by which the network manager tells the resolver what each interface contributes, and the obligations of the resolver that stands between them.
Three roles participate.
The resolver is the one process on a machine that answers names. It
is a stub resolver — a DNS client — not a recursive or authoritative
server: it answers synthetically, from the registry, from its cache, or
by forwarding to an upstream server chosen by policy. It listens on a
Unix socket (§6.4) and on the loopback DNS port (§6.8); it never dials
a client. Peios' resolver is resolvd; another implementation MAY stand
in its place, and the obligations in §6.11 are what "stand in its place"
means.
A client is any process that asks. Most clients never see this
protocol: they call getaddrinfo, and the NSS shim (§6.10) speaks it
for them. Peios-native tools, the operator command and the network
manager speak it directly. A client is not trusted by the resolver
beyond what the control object grants it (§6.4).
The network manager is the process that knows what each interface contributes — servers, search domains, addresses, whether it carries the default route. It publishes that to the resolver over the manager's own control socket (§6.9); the resolver subscribes. Nothing about the live network is ever written to the registry to get from one to the other.
This chapter covers:
- the three doors and the one function behind them (§6.3)
- the native channel: socket, framing, access (§6.4)
- the requests and their replies (§6.5)
- what an outcome and a validation state mean (§6.6)
- the resolution model: scopes, routing, expansion, synthetic names, the cache (§6.7)
- the stub door (§6.8)
- the network manager channel (§6.9)
- what the hosts shim must and must not do (§6.10)
- conformance, by role (§6.11)
- a message reference and the limits (§6.A, §6.B)
What this chapter does not specify: DNSSEC validation and encrypted
transports (reserved; a resolver reports unvalidated until it does
them), multicast DNS, and per-caller resolution policy. Each has a
place held for it in the wire format so that adding it is additive.
6.2 Terminology
Peios / Peios technical reference / PSPU / Name Resolution Interface
Name. A DNS domain name: a sequence of labels. Names compare case-insensitively (RFC 4343). A single-label name has one label; every other name is multi-label. The distinction governs search expansion (§6.7).
Question. A name and a record type (and class, always IN). What a
client asks, and the unit the cache is keyed on.
Record. A resource record: name, type, TTL, and data. The protocol
is record-level: an answer is records, not addresses shaped for a
particular libc structure. lookup (§6.5) is the one convenience that
returns addresses, because that is the question every getaddrinfo
asks.
Scope. What one interface contributes to resolution: its servers, its search domains, its addresses, whether it claims unmatched names, whether it is exclusive. Scopes come from the network manager (§6.9). The fallback scope is the registry's server list, used only when no interface contributes servers.
Up. A scope is up while its interface's level is link or better
(§6.9). Whether it has servers is a separate matter and does not decide
it: a scope that is up with no servers still takes every routing step
it qualifies for, and a question routed to it is unavailable without a
query (§6.7).
Route. The scope a question goes to. Every question goes to exactly one scope (§6.7).
Outcome. How a question ended: found, notfound, or
unavailable (§6.6). The three are not interchangeable and no door may
collapse two into one.
Source. Where an answer came from: synthetic, hosts, cache,
dns, or local (an answer that needed no lookup at all).
Door. A way in: the native socket, the stub listener, or the NSS shim. The doors differ in who can use them and what they can carry; the function behind them does not (§6.3).
Synthetic answer. An answer the resolver gives from what it knows about the machine, before any network: loopback, the machine's own name, static names from the registry, and their reverses.
Control object. The resolver's access-control object, a security descriptor against which every native request is checked (§6.4).
Stub door. DNS itself, on the loopback address 127.0.0.53, for
programs that speak DNS directly (§6.8).
6.3 Three Doors, One Function
Peios / Peios technical reference / PSPU / Name Resolution Interface
A resolver MUST offer three doors and MUST answer every question through the same resolution function (§6.7) whichever door it arrived by.
| Door | Callers | Carries |
|---|---|---|
| Native socket (§6.4) | Peios tools, the network manager, the NSS shim | Records with TTL, source, validation state; the caller's identity |
| Stub listener (§6.8) | Anything that speaks DNS itself — Go's netgo, musl, hickory | DNS; no caller identity |
| NSS shim (§6.10) | Everything via glibc getaddrinfo and gethostbyname | struct hostent / gaih_addrtuple, rendered from the native door |
The rule that matters is the second sentence. A bare printer arriving
at the stub door is expanded with the same search domains, routed to
the same scope and answered from the same cache as printer arriving on
the native socket. A static name from the registry is answered at every
door, so a program that never touches libc sees the same printer as
one that does. There is no configuration a door reads that another does
not, and no file behind any of them.
This is why there is no /etc/hosts: the stub answers static names
synthetically, so a direct-DNS resolver gets them without a file. And it
is why /etc/resolv.conf is a constant — nameserver 127.0.0.53,
options edns0 — shipped by the resolver's package as
/usr/etc/resolv.conf, the bottom layer of the /etc merge, and never
regenerated: it is a pointer to the answerer, not a copy of the answer.
A search list in it would be a second expansion policy the resolver
could not see; there is none.
A resolver MUST NOT offer a fourth policy path — an in-process fallback
in the shim, a files source, a direct-DNS retry — behind any door.
Before the resolver is running, the shim answers localhost itself and
nothing else (§6.10).
6.4 The Native Channel
Peios / Peios technical reference / PSPU / Name Resolution Interface
The native door is a SOCK_STREAM Unix socket at a configured path —
/run/resolvd/resolv.sock on Peios. Every process MAY connect; what a
connection may ask is decided by the control object, not by reaching the
socket.
6.4.1 Framing #
Messages in both directions are length-prefixed MessagePack values in the observability query framing (§3.15):
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | length | Length of the payload in bytes, little-endian |
| 4 | length | payload | A MessagePack map |
A request is a map with a required query string naming the request
(§6.5). A reply is a map with a required ok boolean; when ok is
false it carries error, a string for a person, and nothing else.
When ok is true it carries kind — answer, addresses, status
— or nothing, for a request with no body to return.
A receiver MUST ignore keys it does not know and MUST reject a message
that repeats a key. A resolver MUST refuse a request whose length
exceeds the message ceiling (§6.B) without reading the payload, and
MUST reply with an error rather than closing silently.
6.4.2 One request per connection #
A connection carries exactly one request and one reply. The client sends, the resolver answers, and either side closes. There is no pipelining and no conversation identifier: the shim that generates most of the traffic is loaded into processes that fork, and a connection held across a fork is how a resolver hands back somebody else's answer. Connecting to a Unix socket is cheap; the cache that would make it cheaper lives in the resolver, where every caller shares it.
A resolver MUST bound how long a connection may take to deliver its request, and MUST drop one that exceeds the bound. A client with a half-sent request holds a few bytes of buffer, never the resolver.
6.4.3 The control object #
Every request names a right (§6.5). Before acting, a resolver MUST perform an access check of that right against its control object using the token of the connecting peer — the peer's real token, as the kernel would judge it, never a numeric credential read from the socket.
The control object's descriptor is Machine\System\Network\Dns ControlSecurity when that value holds a valid self-relative descriptor,
else the resolver's compiled default. The default MUST grant
RESOLVER_QUERY to Everyone and RESOLVER_ALL_ACCESS to SYSTEM and
Administrators.
| Right | Value | Governs |
|---|---|---|
RESOLVER_QUERY | 0x0001 | resolve, lookup, reverse, status |
RESOLVER_CONTROL | 0x0002 | flush |
RESOLVER_ALL_ACCESS | 0x000F0003 | Both, with the standard rights |
A denied request is answered with an error reply, not a closed connection.
The peer's identity is what identity-aware resolution policy will key on. This version defines no such policy; a resolver MUST NOT invent one.
6.5 Requests
Peios / Peios technical reference / PSPU / Name Resolution Interface
Each request is a map whose query key names it. Field types are the
MessagePack types named; an address is a string in its usual textual
form; a name is a string in presentation form, with or without a
trailing dot.
6.5.1 resolve — RESOLVER_QUERY #
One question.
| Key | Type | Meaning |
|---|---|---|
name | string | The name |
type | uint | The record type number (1 A, 28 AAAA, 12 PTR, …) |
no_cache | bool | Bypass the cache for this question; default false |
Reply kind: answer:
records is the answer section only. When the name was expanded
(§6.7) the records are at the expanded name, and name on each record
says so.
6.5.2 lookup — RESOLVER_QUERY #
The addresses of a name: what getaddrinfo asks. The resolver asks for
A and/or AAAA, applies search expansion, follows the CNAME chain and
returns what it ends at.
Reply kind: addresses:
6.5.3 reverse — RESOLVER_QUERY #
| Key | Type | Meaning |
|---|---|---|
address | string | An IPv4 or IPv6 address |
Equivalent to resolve of the address's reverse-mapping name with type
PTR; the reply is kind: answer.
6.5.4 status — RESOLVER_QUERY #
No fields. Reply kind: status:
6.5.5 flush — RESOLVER_CONTROL #
No fields. Drops every cached answer. Reply ok: true with no kind.
6.5.6 Unknown requests #
A resolver MUST answer a query it does not know with an error reply.
A client MUST treat an error reply as "not answered", never as
notfound.
6.6 Outcomes and Validation
Peios / Peios technical reference / PSPU / Name Resolution Interface
6.6.1 Outcomes #
The third row is the one that carries the weight. A resolver MUST NOT
report unavailable as notfound, at any door, and MUST NOT cache it:
a source that could have answered and did not is not the same as a name
that does not exist, and recording it as one would let an outage be
remembered as a fact. Each door renders the distinction in its own
terms (§6.8, §6.10).
A resolver MUST answer notfound locally, without a query, for a
single-label name with no applicable search domain and for any name
under .local (§6.7).
6.6.2 Validation #
Every answer and addresses reply carries validation:
| Value | Meaning |
|---|---|
unvalidated | The resolver did not attempt DNSSEC validation. |
secure | Validated, with a chain of trust to a configured anchor. |
insecure | Validated as provably unsigned. |
bogus | Validation failed. |
This version of the interface defines no validating resolver, and a
resolver that does not validate MUST report unvalidated for every
answer from the network. The field exists now so that a client is
written against it from the start: when validation arrives, the change
is a value, not a field, and a client that wanted only secure answers
needs nothing new.
A resolver MUST NOT set the AD bit on a stub-door reply (§6.8) unless
its own validation produced secure. An upstream's AD is an assertion
by a plaintext peer and MUST NOT be forwarded as one.
6.7 The Resolution Model
Peios / Peios technical reference / PSPU / Name Resolution Interface
This is the function behind every door.
6.7.1 Scopes #
Every interface with a link contributes a scope: servers in the
order to try them, search domains, the interface's addresses, whether it
claims unmatched names (default_route), whether it is exclusive, and
its metric. Scopes arrive from the network manager (§6.9); the resolver
holds them and nothing else about the network. The fallback scope is
Machine\System\Network\Dns FallbackServers and ExtraSearchDomains.
6.7.2 Routing #
A question goes to exactly one scope. A resolver MUST NOT send a question to several scopes' servers and take the first answer — parallel fan-out is the mechanism of the classic VPN name leak, and it also lets a hostile network win a race for a name it has no claim to.
The order:
- An exclusive scope, if any interface at
addressedor better is exclusive: it takes every question. Several exclusive scopes: the lowest metric. While an exclusive interface is up no other interface's servers are consulted for anything. - The longest matching search domain. The scope with the longest domain the name ends with; ties by metric.
- The subnet, for a reverse-mapping name: the scope whose interface has an address in the same subnet as the address named.
- The default-route claimant with the lowest metric.
- The lowest-metric scope with servers, when no interface claims the default route.
- The fallback scope, only when no interface contributes servers.
When none of these yields a scope with servers, the question is
unavailable without a query.
This is DNS routing only. Which interface a packet leaves by is the network manager's and the firewall's.
6.7.3 Search expansion #
A single-label name is never sent upstream as it is. It is expanded
with the applicable search domains, in order, and each candidate is
asked in turn until one is not notfound. The applicable domains are
the exclusive scope's when one is up; otherwise every up scope's, in
metric order, followed by the fallback scope's. With no applicable
domain the name is notfound without a query.
A multi-label name is never expanded. There is no ndots setting:
the rule is fixed at one, because every other value is a leak of local
names to the public tree or of public names to the local one.
An answer at an expanded name reports the expanded name (resolved
records on the native channel; a CNAME from the bare name at the
stub door, §6.8).
6.7.4 Synthetic answers #
Before any network, a resolver MUST answer:
localhostand any name under it:127.0.0.1and::1;- the machine's hostname: every unicast address of every interface at
addressedor better, or loopback when there are none; - every name under
Machine\System\Network\Dns\Hosts, exactly and case-insensitively: its addresses. A static name wins over DNS; - the reverse of loopback (
localhost), of a static name's address (the name whose ASCII-lowercased presentation form sorts first in byte order when several names share it), and of the machine's own addresses (the hostname); - any name under
.local:notfound. mDNS is a separate transport (RFC 6762) and a.localquestion MUST NOT reach a DNS server. LLMNR MUST NOT be spoken.
A synthetic answer has source synthetic (or hosts) and TTL 0.
6.7.5 The cache #
The cache is keyed by (name, type, scope), the name folded to lower
case. The scope is part of the key because the same name may have
different answers on different interfaces (split horizon), and because
when an interface's servers change or the interface goes away, every
answer learned through it MUST be discarded: what a VPN's servers said
dies with the VPN.
Positive answers live for their least TTL, capped (§6.B). Negative
answers — notfound, and found with no records — live for the lesser
of the authority section's SOA record TTL and its MINIMUM field (RFC 2308
§5), capped lower. unavailable
is never cached. A hit reports TTLs reduced by the time spent in cache.
6.7.6 Upstream behaviour #
A resolver MUST send each query from a fresh source port, MUST use a random message identifier, and MUST randomise the case of the question name and require the reply to echo it exactly (0x20 encoding). Both the identifier and the case bits MUST come from a cryptographically secure generator: a predictable one hands a spoofer back the entropy these measures exist to add. A reply whose identifier, question or case pattern does not match MUST be ignored, not treated as a failure.
Queries are UDP with EDNS0 advertising the buffer size in §6.B; a
truncated reply is retried over TCP to the same server. A server that
does not answer within the per-server timeout, or answers SERVFAIL,
REFUSED or a format error, is demoted — tried after its peers —
for the demotion period; the question moves to the next server. After
the attempt limit the question is unavailable. Servers within a scope
are tried in the scope's order, healthy ones first; never in parallel.
6.8 The Stub Door
Peios / Peios technical reference / PSPU / Name Resolution Interface
A resolver MUST listen for DNS over UDP and TCP on 127.0.0.53 port 53
and MUST NOT listen on any other address: not ::1, which a local DNS
server may legitimately want, and never a routable one. It MUST ignore
a datagram or connection whose source is not a loopback address.
The stub door exists for software that does not go through libc — a Go
binary built with netgo, a musl program, a Rust resolver library — and
it is non-optional: the constant /etc/resolv.conf points at it. It is
also anonymous: DNS carries no caller identity, and the door is not
governed by the control object. Everything on the machine may ask.
6.8.1 Rendering #
A query is answered by the same function as every other door (§6.7), including search expansion of a single-label question. The reply is rendered as:
When the answer is at an expanded name, the reply's answer section
MUST begin with a CNAME from the question name to the expanded name,
so that a client checking names sees a well-formed chain. RA is set;
AA is not; AD is not, per §6.6.
A query with an opcode other than QUERY is answered NOTIMP; one
with other than exactly one question, or that cannot be decoded, is
answered FORMERR when there is enough of a header to echo. A reply
larger than the client's advertised buffer (or 512 bytes without EDNS)
is truncated with TC set; TCP carries the whole answer.
6.8.2 Binding the port #
Port 53 is below the privileged floor. On Peios the resolver's service
SID holds a port reservation for tcp,udp:53, shipped as a registry
seed by the resolver's package; the resolver needs no privilege to bind
it. A resolver MUST NOT require SeTcbPrivilege or any capability for
its listeners.
6.9 The Network Manager Channel
Peios / Peios technical reference / PSPU / Name Resolution Interface
The live facts of the network — which interface has which servers, which carries the default route — never reach the resolver through the registry, whatever the manager also records there. They go from the network manager to the resolver directly, over the manager's own control socket, and they are gone when the manager is.
6.9.1 subscribe #
The resolver connects to the manager's control socket
(/run/netd/control.sock on Peios; framing as §6.4) and sends
{"query": "subscribe"}, which requires the manager's query right
(NETWORK_QUERY). The manager replies at once with a snapshot and
then keeps the connection open, sending a fresh snapshot every time the
picture changes, until either side closes. This is the one request on
that socket that holds a connection.
A snapshot is ok: true, kind: snapshot:
| Key | Type | Meaning |
|---|---|---|
hostname | string | The machine's name as set by the manager; empty when unset |
scopes | array of map | One per managed interface with a link, in metric order |
Each scope:
A snapshot is the whole picture, not a delta. It is a few hundred bytes, and a whole picture cannot be misapplied out of order. The resolver MUST replace its scopes with each snapshot; a scope whose servers differ from before loses its cached answers (§6.7).
6.9.2 Failure and reconnection #
The manager MAY drop a subscriber it cannot write to. The resolver MUST reconnect with backoff whenever the connection is lost or the manager is absent, and MUST keep answering meanwhile: synthetic names need no scope, and the scopes it holds remain valid until replaced. A resolver MUST NOT treat the manager's absence as a reason to refuse questions.
The manager merges the profile's static DNS keys with the lease itself;
the resolver reads Profiles\ for nothing. The registry keys the
resolver reads are exactly Machine\System\Network\Dns and its
subkeys; it MAY watch the parent Machine\System\Network so that the
key's creation is seen. When the manager reports no hostname the
resolver uses the kernel's.
6.10 The Hosts Shim
Peios / Peios technical reference / PSPU / Name Resolution Interface
The shim is the NSS module glibc's hosts database reaches, and on
Peios the only one it can reach: glibc is patched so that hosts, like
the identity databases, is not configurable and names peios_net
whatever nsswitch.conf says. There is no files source behind it and
no /etc/hosts for one to read.
6.10.1 Obligations #
A shim MUST:
- Forward every question to the native channel (§6.4) as
lookuporreverse, one connection per libc call, and render the reply. It MUST NOT hold a connection across calls. - Answer
localhost, and any name under it, and the reverse of a loopback address, itself — with no socket. It is the one name a machine can never be without, and the only one the shim knows. - Answer
NSS_STATUS_UNAVAILfor everything else when the resolver cannot be reached. There is nothing behind it. - Report an over-full caller buffer as
NSS_STATUS_TRYAGAIN, with*errnop = ERANGEand*h_errnop = NETDB_INTERNAL(-1), so glibc retries with a larger one.
A shim MUST NOT read /etc/hosts, /etc/resolv.conf or any file;
MUST NOT speak DNS itself; and MUST NOT cache. Each would be a second
policy path the resolver cannot see.
The complete buffer-exhaustion triple is required by the
glibc NSS interface:
NETDB_INTERNAL tells the caller to examine errnop, including its
ERANGE buffer-size indication.
6.10.2 Status mapping #
The unavailable row is the one that matters: a shim MUST NOT render it
as NOTFOUND. A caller that retries gets its answer when the network
comes back; a caller told "no such host" may remember that.
h_name is the reply's canonical name. TTLs are reported through
ttlp where glibc offers it, as the least TTL among the addresses.
6.10.3 Entry points #
_nss_peios_net_gethostbyname4_r, gethostbyname3_r,
gethostbyname2_r, gethostbyname_r, gethostbyaddr2_r,
gethostbyaddr_r. The module's SONAME is libnss_peios_net.so.2 and
it is installed in glibc's configured library directory; it links
against libc and the wire codec and nothing else, since it is loaded
into every process that resolves a host.
6.11 Conformance
Peios / Peios technical reference / PSPU / Name Resolution Interface
A conforming implementation MUST satisfy every requirement in this chapter. This section collects them by role.
6.11.1 Resolver obligations #
6.11.1.1 Doors #
- Offer the native socket, the stub listener and answer the shim, and run one resolution function behind all three (§6.3).
- Offer no other policy path: no files, no in-shim DNS, no fallback (§6.3).
6.11.1.2 Native channel #
- Frame as §6.4; refuse an oversized request without reading it, with an error reply (§6.4).
- Ignore unknown keys; reject duplicate keys (§6.4).
- One request per connection; bound the time a request may take to arrive (§6.4).
- Access-check every request against the control object with the
peer's real token; default Everyone
RESOLVER_QUERY, SYSTEM and Administrators all (§6.4). - Answer unknown requests and denied requests with error replies (§6.4, §6.5).
6.11.1.3 Outcomes #
- Never report
unavailableasnotfound; never cacheunavailable(§6.6). - Report
unvalidateduntil validating; never forward an upstream'sAD(§6.6).
6.11.1.4 Resolution #
- Route every question to exactly one scope, in the order of §6.7; never fan out.
- Honour an exclusive scope absolutely while it is up (§6.7).
- Expand single labels with applicable domains only; never send one
bare; never expand a multi-label name; no
ndots(§6.7). - Answer the synthetic names of §6.7 before any network; never
forward
.local; never speak LLMNR. - Key the cache by scope; flush a scope whose servers change or that goes away; cap TTLs as §6.B (§6.7).
- Fresh source port, random identifier, 0x20 case with exact echo; ignore mismatched replies (§6.7).
- EDNS0 with the §6.B buffer; TCP on truncation; demote failing servers; bounded attempts (§6.7).
6.11.1.5 Stub door #
- Listen on
127.0.0.53only, UDP and TCP; ignore non-loopback sources (§6.8). - Render outcomes as §6.8; CNAME from a bare question to its expanded
name;
NOTIMPandFORMERRas §6.8; truncate over the buffer. - Bind without privilege (§6.8).
6.11.1.6 Network manager channel #
- Subscribe; replace scopes on every snapshot; reconnect with backoff; keep answering while disconnected (§6.9).
- Read only
Machine\System\Network\Dnsfrom the registry (§6.9).
6.11.2 Network manager obligations #
- Answer
subscribewith a snapshot and stream one on every change on the same connection (§6.9). - Send whole snapshots, never deltas; merge profile and lease facts itself (§6.9).
6.11.3 Client obligations #
- Treat an error reply as "not answered", never as
notfound(§6.5). - Be written against
validation(§6.6).
6.11.4 Shim obligations #
- One connection per libc call; answer
localhostalone;UNAVAILwhen the resolver is unreachable;ERANGEretry (§6.10). - No files, no DNS, no cache (§6.10).
- Map outcomes as §6.10; never render
unavailableasNOTFOUND.
Appendix 6.A Message Reference
Peios / Peios technical reference / PSPU / Name Resolution Interface
6.A.1 Native channel requests #
query | Right | Reply kind | Defined in |
|---|---|---|---|
resolve | RESOLVER_QUERY | answer | §6.5 |
lookup | RESOLVER_QUERY | addresses | §6.5 |
reverse | RESOLVER_QUERY | answer | §6.5 |
status | RESOLVER_QUERY | status | §6.5 |
flush | RESOLVER_CONTROL | (none) | §6.5 |
6.A.2 Network manager channel #
query | Right | Reply kind | Defined in |
|---|---|---|---|
subscribe | NETWORK_QUERY | snapshot, streamed | §6.9 |
6.A.3 Rights #
| Constant | Value | Defined in |
|---|---|---|
RESOLVER_QUERY | 0x00000001 | §6.4 |
RESOLVER_CONTROL | 0x00000002 | §6.4 |
RESOLVER_ALL_ACCESS | 0x000F0003 | §6.4 |
6.A.4 Enumerations #
| Field | Values | Defined in |
|---|---|---|
outcome | found, notfound, unavailable | §6.6 |
validation | unvalidated, secure, insecure, bogus | §6.6 |
source | local, synthetic, hosts, cache, dns | §6.2 |
family | any, inet, inet6 | §6.5 |
level | link, addressed, routed; never absent in a snapshot, since an absent interface contributes no scope | §6.9 |
6.A.5 Paths and addresses (Peios) #
| Item | Value |
|---|---|
| Native socket | /run/resolvd/resolv.sock |
| Stub listener | 127.0.0.53:53, UDP and TCP |
| Network manager socket | /run/netd/control.sock |
| Registry key | Machine\System\Network\Dns |
| Shim | libnss_peios_net.so.2 |
| Compat file | /usr/etc/resolv.conf, constant |
Appendix 6.B Limits
Peios / Peios technical reference / PSPU / Name Resolution Interface
The upstream ceiling leaves synthetic and cached answers unaffected once a query has passed its door's admission limits. Native and stub TCP reply writers have separate 256-frame queues and one-second write deadlines.
An implementation MAY adjust these; it MUST document the values it
uses. The relations that must hold: the shim's own timeout exceeds
attempts × per-server timeout, so a slow upstream is reported by the
resolver as unavailable rather than by the shim as a timeout.
7.1 Scope and Roles
Peios / Peios technical reference / PSPU / Job Submission
This chapter defines the jobs channel: the interface by which a process asks a Peios service manager to run an arbitrary program under its supervision, with an identity the requester is entitled to confer, and to manage that program afterwards.
A job submitted this way is a job in the sense of §4.2 — one process execution, supervised by the manager — whose parent is the process that asked for it rather than a service definition. The manager forks it, installs its token, contains it, records it and reaps it exactly as it does a service's process. What differs is where the definition came from and who is entitled to manage the result.
Two shapes of use are intended, and the chapter is written so that one primitive serves both:
- A task: a program that runs to completion and reports progress on the way — a backup, a transfer, a migration — submitted by a broker on behalf of the principal it is acting for.
- A session: a long-lived program managed from outside — a per-logon host process for an interactive session — submitted with a freshly issued logon token and one end of a channel back to its submitter.
Nothing in the protocol distinguishes the two. They are the same message with different field values.
7.1.1 The roles #
The manager is the process that supervises services and listens on the control and notification channels of §4. It listens on the jobs channel too. On Peios this is peinit.
A submitter connects to the jobs channel, submits a job, and MAY manage the jobs it submitted. A submitter is any process the socket's descriptor admits; it holds no relationship with the manager beyond the one its token establishes.
A job is the process the manager started on a submitter's behalf. It speaks the notification channel of §4 about itself, in the same way a service does, and this chapter adds nothing to that role beyond the progress fields defined in §4.19.
A client of the control channel (§4) MAY observe and stop jobs it is entitled to, alongside services. That role is unchanged here; §4.14 defines the commands.
Requirements are stated against the role, not the program. A service that submits jobs is a submitter when it does so; a job that submits jobs of its own is a submitter too.
7.1.2 What this chapter covers #
- the jobs socket, how it is reached, and who may reach it
- message framing on it, and how a message carries a token and descriptors alongside its content
- how the identity a job runs as is established, and the rule that the manager never installs an identity the submitter could not already act as
- the job definition a submitter sends, and what the manager validates
- the job view the manager reports, its states and its causes
- the commands a submitter may issue about its own jobs, and the Security Descriptor that decides who else may
- passing descriptors into a job, and receiving its output
- the error vocabulary, the extension rules and the conformance requirements
7.1.3 What this chapter does not cover #
- The notification channel, including the progress fields a job sends. A job reports on itself exactly as a service does. §4.16–§4.19.
- Observing and stopping jobs from the control channel. The
job-list,job-statusandjob-stopcommands are part of the control channel's command set and are defined in §4.14, using the job view this chapter defines. - How the manager supervises the job once it is running — cgroup containment, output capture, reaping, the child setup sequence. That is the manager's own design, documented in its reference manual.
- How a token is conveyed on a socket, and what the kernel checks
when it is. Peer-token capture,
KACS_SCM_TOKEN, the impersonation gates and the impersonation-level ratchet are kernel operations, documented in the Peios Kernel TRM §3.5. - How a submitter obtained the token it attaches. From a peer it is serving, from a logon it originated (PGSS §2), or from anywhere else — the manager does not ask, because the kernel has already decided whether the submitter may convey it.
7.2 Terminology
Peios / Peios technical reference / PSPU / Job Submission
Terms defined in §4.2 — service, job, activation generation, right, terminal state, datagram — are used here with the same meaning and are not redefined.
Submitted job. A job whose definition arrived on the jobs channel rather than from a service definition. It has no service, no operation, and no activation generation; it belongs to a submitter.
Submitter. The principal that submitted a job: the identity the
connection carried when the submit message was read. It is recorded
on the job and is the owner of the job's Security Descriptor.
Job identity. The identity a submitted job's process runs as. It
is either the submitter's own primary identity or one the submitter
attached to the submit message; it is never named by a field.
Attached token. A token conveyed with a message through the
kernel's per-message identity facility (KACS_SCM_TOKEN; the Peios
Kernel TRM §3.5). The kernel has verified, at the moment of sending,
that the sender could act as that identity at the level recorded on
the token.
Attached descriptor. A file descriptor conveyed with a message
through SCM_RIGHTS, to be given to the job or used as its output
sink.
Message. One SOCK_SEQPACKET record on the jobs channel, carrying
exactly one JSON object and, optionally, one attached token and any
number of attached descriptors. The jobs channel has no frames in the
sense of §4.2: the transport delimits messages.
Job view. The JSON object by which the manager reports a job, on either channel. §7.7.
Live job. A submitted job in a non-terminal state. Quotas count live jobs.
7.3 The Jobs Socket
Peios / Peios technical reference / PSPU / Job Submission
The manager MUST listen on a Unix SOCK_SEQPACKET socket at a
well-known path. On Peios that path is:
/run/services/peinit/jobs.sock
The socket MUST exist for as long as the manager is serving, and the manager MUST unlink it when it stops. The manager MUST create the listening socket and every accepted connection with close-on-exec set, so that no connection descriptor is inherited by a job it starts.
7.3.1 Why a second socket #
The control channel (§4.4) is an administrator's door: its commands
change the state of the system's services, and the descriptor on its
socket admits the principals entitled to do that. Submitting a job is
a different permission held by a different population. Any principal
may reasonably be allowed to run a program under supervision, subject
to a quota, without being allowed to stop registryd.
Putting the two on one socket would force one descriptor to serve both populations. Two sockets let the filesystem say who may submit and who may administer, separately, with no policy inside the manager.
7.3.2 Why a sequenced-packet socket #
A message on this channel carries more than bytes. It carries the
token a job is to run as, and the descriptors a job is to be given,
as ancillary data. The kernel associates ancillary data with the
record it was sent with, and on a stream that association is only as
precise as the reader's record boundaries. SOCK_SEQPACKET makes one
send one message and one identity (the Peios Kernel TRM §3.5), so
the manager never has to decide which request an attached token or
descriptor belongs to.
The cost is that a message has a maximum size (§7.A) and cannot be split. A definition is small; nothing here needs to be large.
7.3.3 Who may submit #
Being able to connect is the submission permission. The manager
MUST NOT perform an access check of its own before accepting a
submit; the check is the one the kernel performs against the
socket's inode descriptor when the submitter connects, and the manager
relies on it entirely.
The manager MUST therefore ensure the socket, and each directory containing it, carries a Security Descriptor admitting exactly the principals it intends to let submit. The default a Peios service manager applies is in §7.A. §4.3's warning applies with full force: a socket that inherits nothing is reachable by nobody, and one in a permissive place is reachable by anything.
What a submitter may do to a job is a separate question, answered by the job's own descriptor (§7.8). Reaching the socket confers the right to submit, and nothing else.
7.3.4 A connection #
A submitter connects, issues one or more messages, and closes. The manager MUST NOT require a submitter to issue any message before another, and MUST NOT hold state across connections beyond the jobs themselves: a connection carries an identity (§7.5) and nothing else.
Messages on one connection MUST be answered in the order they were received. The manager MAY read no further messages from a connection while a response on it is outstanding.
Closing a connection MUST NOT affect any job submitted on it. A job belongs to its submitter's identity, not to the connection that submitted it; a submitter that reconnects — after a restart, say — finds its jobs where it left them.
7.3.5 Limits #
The manager MUST enforce three limits on the channel and one on the submitter, and MUST make their values discoverable to an administrator through the same surface that sets them. The values a Peios service manager uses by default are in §7.A.
Concurrent connections. A connection accepted while the manager is already at its limit MUST be closed at the socket level, without a response, and a submitter MUST treat an immediate close with no response as a refusal rather than a protocol error.
Message size. A message whose content exceeds the limit MUST be
answered with REQUEST_TOO_LARGE. The manager MUST detect a message
the kernel truncated to fit its receive buffer (MSG_TRUNC) and MUST
NOT interpret the truncated content: a truncated JSON object is
malformed by construction, but a truncated attachment list is not,
and the manager MUST NOT act on a request whose attachments it did
not fully receive.
Idle timeout. A connection with no message outstanding MAY be
closed once it has been idle for the configured period, without a
response. A connection blocked on a wait (§7.8), or on a submit
whose job has not yet confirmed exec, is not idle and MUST NOT be
closed for idleness.
Live jobs per submitter. The manager MUST bound the number of live
jobs one submitter may hold, counted by the submitter's user SID, and
MUST answer a submit that would exceed the bound with
QUOTA_EXCEEDED, creating nothing. The manager MAY exempt the SYSTEM
principal (S-1-5-18). A job that reaches a terminal state stops
counting at once, not when its record is dropped (§7.7).
7.4 Messages
Peios / Peios technical reference / PSPU / Job Submission
7.4.1 Content #
Every message in both directions carries exactly one JSON object, serialised compactly, as the whole of the record's content. There is no terminator: the transport delimits messages, and the manager MUST NOT require or emit a trailing newline.
Content is UTF-8. The manager MUST reject content that is not
well-formed UTF-8 with MALFORMED_REQUEST.
The rules of §4.5 for what is malformed apply unchanged: an empty
message, content that is not valid JSON, and valid JSON that is not an
object are all MALFORMED_REQUEST. The manager MUST NOT emit
pretty-printed JSON. Timestamps follow §4.5.
7.4.2 Attachments #
A request MAY carry ancillary data. Two kinds are defined:
| Level | Type | Meaning | Applies to |
|---|---|---|---|
SOL_KACS | KACS_SCM_TOKEN | The token the job is to run as. At most one. | submit |
SOL_SOCKET | SCM_RIGHTS | Descriptors for the job, or an output sink. | submit |
The manager MUST receive every message with room for both kinds and
for the number of descriptors in §7.A. It MUST detect ancillary data
the kernel could not fit (MSG_CTRUNC) and MUST answer the message
INVALID_ARGUMENTS without acting on it: a request whose attachments
were partly lost does not describe the job the submitter meant.
Attachments on a message whose command does not use them MUST be
closed and ignored. A stray descriptor on a status is not an error,
in the same spirit as §4.8's rule for fields that do not apply — but
it is closed, because nothing else could own it.
The manager MUST close every attached descriptor it does not hand to a
job or adopt as an output sink, on every path including error paths.
A submitter MUST assume the manager has taken ownership of everything
it attached to a submit, whatever the outcome, and MUST NOT rely on
the manager leaving a descriptor open.
A response MAY carry ancillary data. One kind is defined:
| Level | Type | Meaning | Carried by |
|---|---|---|---|
SOL_SOCKET | SCM_RIGHTS | The job's process handle. Exactly one. | A submit response for a running job. §7.6 |
A submitter that receives a response without room for its ancillary data does not receive the handle, and MUST NOT treat that as an error: the response content is complete without it.
7.4.3 Requests #
A request is one JSON object.
| Field | Type | Required | Meaning |
|---|---|---|---|
command | string | always | submit, status, wait, stop or signal. |
job_id | string | all but submit | The job. |
wait | bool | no | For stop: block until the job is terminal. §7.8 |
for | string | no | For wait: ready or terminal. §7.8 |
signal | integer | for signal | The signal number. §7.8 |
The submit definition fields are in §7.6.
command MUST be present and MUST be a string naming one of the five
commands. Otherwise the manager MUST answer INVALID_COMMAND.
job_id MUST be present and a string for every command but submit.
Its absence, a non-string value, or a value that is not a well-formed
identifier MUST be answered INVALID_ARGUMENTS.
A field the command does not use MUST be ignored, not rejected. A field the manager does not recognise MUST be ignored (§7.11).
7.4.4 Responses #
Every response carries a status field, exactly "ok" or "error".
The success shape for every command is the job view (§7.7):
The error shape is §4.9's:
code MUST be one of the values in §7.10. message is not
normative; a submitter MUST NOT parse or branch on it.
7.4.5 Closing after an error #
§4.5's distinction applies. On a sequenced-packet socket a
frame-level failure is narrower — the transport cannot lose
synchronisation — so only REQUEST_TOO_LARGE and a truncated message
close the connection. Every other error is command-level: the manager
MUST send the error and keep the connection open.
A submitter MUST NOT assume a connection survives an error response.
7.5 Identity
Peios / Peios technical reference / PSPU / Job Submission
Every submission involves two identities, and the protocol keeps them apart.
The submitter is who asked. The job identity is who the job runs as. They are the same principal in the simplest case and different ones in the case the channel exists for — a broker running a program as the client it is serving.
7.5.1 The submitter #
The manager MUST establish the submitter's identity from the kernel, as §4.6 requires of the control channel: the peer's token, captured when the connection was accepted, used for every message on the connection. A submitter MUST NOT be able to assert who it is, and a change of identity mid-connection has no effect on it.
The submitter's identity is what the manager records on the job as its submitter, what it counts quotas against, and what it names as the owner of the job's descriptor (§7.8). It MUST be the identity the peer thread was acting under when it connected — so a submitter that connects while impersonating is recorded as the principal it was impersonating. A submitter that intends to own the jobs it submits, as itself, connects as itself and conveys any other identity per message.
7.5.2 The job identity #
The job identity is established in exactly one of two ways, and the manager MUST NOT provide a third.
No token attached — the submitter's own primary. The manager MUST
open the primary token of the connecting process — identified by
the kernel through the peer's process handle (SO_PEERPIDFD), never
by a PID the submitter names — duplicate it, and install the duplicate
on the job.
The job then has the identity a child of the submitter would have had: its primary token, with no impersonation inherited (the Peios Kernel TRM §3.2). A submitter that was impersonating when it connected therefore produces a job running as its own identity, not the impersonated one, while being recorded as a submitter under the impersonated one. The two differ deliberately, and a submitter that wants the impersonated identity on the job attaches it.
A token attached — the identity conveyed with the message. The
manager MUST use the token the kernel delivered with the submit
message, duplicate it to a primary token, and install the duplicate on
the job.
The kernel gated the attach: it ran the impersonation gates with the submitter as installer and recorded on the token the lower of the socket's level and the source's own (the Peios Kernel TRM §3.5). The attached token is therefore a kernel-verified statement that the submitter could have acted as this identity, at this level, when it sent this message. The manager MUST rely on that statement and MUST NOT re-derive it.
7.5.3 What the manager checks #
The manager MUST refuse, with BAD_TOKEN:
- more than one attached token on a message;
- an attached token whose impersonation level is below Impersonation. An Identification-level token cannot pass an access check and an Anonymous one is not an identity to run a process as; neither can become a primary (the Peios Kernel TRM §3.2.4);
- an attached token the manager cannot duplicate.
The manager MUST NOT refuse a token because of who it names. Whether the submitter may run a job as that identity was decided by the kernel when the token was conveyed, and a second decision inside the manager would be a second copy of the same policy, able only to disagree with the first.
7.5.4 The level carries through #
The duplicated primary carries the attached token's impersonation level, and everything the job does inherits it (the Peios Kernel TRM §3.5.1). A submitter that needs the job to convey Delegation — to a session that will authenticate to remote services on its user's behalf — MUST set its own jobs connection to Delegation before attaching, because the default level on a socket is Impersonation and the attach is clamped to it. A job started at Impersonation cannot be raised afterwards, by anyone.
7.5.5 What the manager never does #
The manager MUST NOT accept a primary token passed as a plain
descriptor (SCM_RIGHTS) as a job identity. A descriptor passed that
way is not gated by the kernel: whoever holds an fd can pass it, and
the recipient learns nothing about whether the sender could act as the
identity it names. Every job identity on this channel is one the
kernel verified the submitter could act as, and a path that skips the
verification would be the only one worth attacking.
The manager MUST NOT install on a job any token it obtained by a route
other than the two above. In particular it MUST NOT open a token
through a submitter-supplied PID or descriptor, and MUST NOT let a
submit field name an identity. There is no identity field, and a
manager MUST NOT add one (§7.11).
7.6 Submitting a Job
Peios / Peios technical reference / PSPU / Job Submission
A submit carries the whole definition of the job in the request
object. There is no persistent definition, no name, and no policy: a
submitted job runs once, as described, and is then gone.
7.6.1 The definition #
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
image_path | string | yes | — | The program to execute. An absolute path. |
arguments | array of strings | no | [] | Its arguments, after the program name. |
environment | object of string → string | no | {} | Variables to set. §7.6 below. |
working_directory | string | no | / | The working directory. An absolute path. |
description | string | no | "" | A human-readable label, for views and records. |
timeout | integer | no | 0 | Seconds the job may run before it is stopped. 0 is no limit. |
stop_timeout | integer | no | §7.A | Seconds between the termination signal and the kill. |
readiness | string | no | none | none, or notify to wait for READY=1. |
readiness_timeout | integer | no | §7.A | Seconds a notify job has to become ready. |
success_exit_codes | array of integers | no | [] | Non-zero exit codes that count as success. |
descriptors | array of strings | no | [] | Names for the descriptors to inject. §7.9 |
output | bool | no | false | Whether the last attached descriptor is an output sink. §7.9 |
security_descriptor | string | no | §7.8 | The job's descriptor, in SDDL. §7.8 |
Nothing here restarts, depends, probes, or schedules. A program that
needs a restart policy, dependencies, a health check or a trigger is a
service, and its definition belongs in the registry (§4.1). The
manager MUST NOT accept a policy field on a submit, and MUST NOT add
one (§7.11).
7.6.1.1 Validation #
The manager MUST answer INVALID_ARGUMENTS, creating nothing, when:
image_pathis absent, not a string, empty, not absolute, or contains a NUL byte;working_directoryis present and not an absolute string, or contains a NUL byte;- any element of
argumentsis not a string or contains a NUL byte; - any key of
environmentis empty, contains=, or contains a NUL byte; or any value contains a NUL byte; - any of
timeout,stop_timeout,readiness_timeoutis present and not a non-negative integer, orstop_timeoutis0; readinessis present and not one of the two values;- any element of
success_exit_codesis not an integer in 0–255; descriptorsdoes not match the attached descriptors (§7.9);security_descriptoris present and is not a valid descriptor with an owner and a DACL (§7.8);- the total size of the resulting
argvand environment exceeds what the manager can pass toexecve.
The manager MUST NOT validate that image_path exists or is
executable. That is decided at execve, by the job's own token
against the file's descriptor, and a job whose program cannot be run
becomes a failed job (§7.7) rather than a refused submission. Checking
in advance would either check with the wrong identity or duplicate the
kernel's decision.
7.6.1.2 The environment #
The manager MUST build the job's environment as it builds a service's,
with environment in the place a service definition's own variables
occupy: above any manager-wide layer an administrator configures, and
below the protocol variables the manager sets last. A submitter MUST
NOT be able to override NOTIFY_SOCKET, LISTEN_FDS,
LISTEN_FDNAMES or LISTEN_PID.
The manager sets no HOME, USER, LOGNAME or SHELL — it knows
nothing about the job identity beyond its token, and a submitter that
knows the principal's home directory supplies it. A submitter that
originated a logon has these from the logon's profile (PGSS §2.9).
7.6.2 What the manager does #
On an accepted submit the manager MUST, in order:
- Establish the job identity (§7.5) and refuse with
BAD_TOKENif it cannot. - Check the submitter's quota and refuse with
QUOTA_EXCEEDEDif the job would exceed it. - Create the job record, with its identifier, in the
createdstate, owned by the submitter with the descriptor of §7.8. - Start the process — install the identity, set up its streams, inject descriptors, execute — as it starts a service's main process.
- Answer the
submit.
Steps 1 and 2 MUST both precede step 3: a refused submission MUST leave no record, emit no lifecycle event, and consume no quota.
7.6.3 When the submit is answered #
The manager MUST answer a submit when the job has left the
created state: when the process has confirmed a successful
execve, or when starting it has failed.
It MUST NOT answer earlier. A submitter holding a response holds a job that either exists as a running process or has definitively failed to — never one whose fate is still being decided in the setup between fork and exec.
It MUST NOT answer later. In particular a readiness: notify job is
answered when it is running, not when it is ready; a submitter that
wants to block until readiness follows with a wait (§7.8).
7.6.4 The response #
The response is the job view (§7.7).
If the job is running, the response MUST carry the job's process
handle — a pidfd referring to the job's main process — as an attached
descriptor. The handle lets the submitter observe the process's exit
with poll or waitid, and signal it, with no further protocol; the
kernel applies its ordinary process access rules to whatever the
submitter does with it. A submitter that declined to receive ancillary
data simply does not get the handle (§7.4).
If the job is failed, the response carries no descriptor, and the
view's cause says why (§7.7). The submission itself succeeded — a
job was created, recorded and reported — so the response is
"status": "ok". Only a submission the manager refused outright, at
steps 1–3, is an error response.
7.7 The Job View
Peios / Peios technical reference / PSPU / Job Submission
The job view is the shape every successful response on this channel
carries, and the shape job-status, job-list and job-stop return
on the control channel (§4.14), which adds one field to it. It is
defined once, here.
| Field | Type | Meaning |
|---|---|---|
id | string | The job's identifier. |
type | string | Always submitted on this channel. §4.B |
state | string | §7.B. |
cause | string or null | Why the job ended, where the manager decided it. §7.B |
submitter | string | The submitter's user SID. |
identity | string | The job identity's user SID. |
logon_session | integer | The job identity's logon session identifier. |
description | string | As submitted. |
image_path | string | As submitted. |
pid | integer or null | The process identifier, while there is a process. |
ready | bool or null | For a notify job: whether READY=1 has arrived. Null for a none job. |
exit_code | integer or null | The exit code, when the process exited. |
exit_signal | integer or null | The signal, when the process was killed. |
status_text | string or null | The most recent STATUS the job sent (§4.19). |
progress | object or null | The most recent progress the job sent (§4.19). |
created_at | string | When the record was created. §4.5 |
started_at | string or null | When exec was confirmed. |
ended_at | string or null | When the job became terminal. |
A field that does not apply MUST be present and null, never omitted.
These are all the view's fields on this channel. On the control
channel each view carries one more, granted: the job rights the
caller holds on the job (§4.14). It describes the caller rather than
the job, and a submitter on this channel is not given it.
identity and submitter are user SIDs. A client that needs the
identity's groups, privileges or integrity does not get them here;
they are the job's, not the view's.
progress carries current (integer), total (integer or null),
bounded (bool) and unit (string or null), in the representation
§4.19 defines. A progress object whose total is null is either
unbounded or not yet bounded, and bounded distinguishes them.
7.7.1 States #
| State | Process? | Terminal? | Meaning |
|---|---|---|---|
created | Not yet | No | The record exists; exec has not been confirmed. Never visible on this channel — a submit is answered only after leaving it. |
running | Yes | No | Exec succeeded and the process is alive. |
completed | No | Yes | The process exited with 0 or a code in success_exit_codes. |
failed | No | Yes | The process exited otherwise, was killed by a signal, or never ran because setup failed. |
abandoned | Yes, unkillably | Yes | The process survived the kill. The manager has stopped supervising it. |
These are the states of every job the manager runs, not a set
invented for this channel; a client that understands them for a
service's main job understands them here. A stopped job whose
process died to the termination signal is failed with
exit_signal set, and cause says the stop was asked for.
Which fields are populated is a function of state, and a client MAY rely on it:
| State | pid | started_at | ended_at | exit_code / exit_signal |
|---|---|---|---|---|
running | set | set | null | null |
completed | null | set | set | exactly one set |
failed, process ran | null | set | set | exactly one set |
failed, setup failed | null | null | set | both null |
abandoned | set | set | set | both null |
exit_code and exit_signal are never both set: a process exits or
is killed. For abandoned, ended_at records when supervision
stopped; nothing exited.
7.7.2 Causes #
cause is set when the manager itself brought the job to its end, or
decided it could not start, and null when the process ended of its own
accord — a clean exit, a crash, a signal from elsewhere.
| Value | State | Meaning |
|---|---|---|
parent_setup_failure | failed | The manager could not prepare the launch: no token, no cgroup, no fork. No process existed. |
pre_exec_failure | failed | The child's setup between fork and exec failed, or exec itself did. |
readiness_timeout | failed | A notify job did not send READY=1 within readiness_timeout. The manager stopped it. |
timeout | failed | The job ran longer than timeout. The manager stopped it. |
explicit_stop | failed | A stop or job-stop was issued and the process died to it. |
shutdown | failed | The system was shutting down and the manager stopped it. |
process_unkillable | abandoned | The process survived the kill. |
A stop whose process handled the termination signal and exited 0
produces completed with cause explicit_stop: the manager asked, the
process agreed, and both facts are recorded.
7.7.3 Retention #
The manager MUST hold a terminal job's record for at least a grace period after it reaches a terminal state, so that a submitter polling for the outcome can retrieve it. The value a Peios service manager uses is in §7.A.
An identifier that never existed and one whose record has been dropped
MUST both be answered UNKNOWN_JOB, and a submitter MUST NOT
distinguish them (§4.14's rule for operations, applied to jobs).
A job that is terminal stops counting against its submitter's quota at once; its record's retention is for the submitter's benefit, not a cost to it.
7.8 Managing a Job
Peios / Peios technical reference / PSPU / Job Submission
Four commands act on an existing job. Every one is authorised against the job's own Security Descriptor, using the connection's token, with the rights and mapping below. There is no command the manager performs on a job without a check, and no principal exempt from one — the submitter included.
7.8.1 Rights #
| Right | Bit | Grants |
|---|---|---|
JOB_QUERY | 0x0001 | Read the job view; wait on the job. |
JOB_STOP | 0x0002 | Stop the job. |
JOB_SIGNAL | 0x0004 | Send a signal to the job's process. |
JOB_ALL_ACCESS | 0x0007 | All three. |
| Generic right | Maps to |
|---|---|
GENERIC_READ | JOB_QUERY |
GENERIC_WRITE | JOB_STOP | JOB_SIGNAL |
GENERIC_EXECUTE | JOB_STOP | JOB_SIGNAL |
GENERIC_ALL | JOB_ALL_ACCESS |
The same rights and mapping govern the control channel's job commands (§4.7).
7.8.2 The job's descriptor #
Every job carries a Security Descriptor from the moment its record
exists. Unless the submit supplied one, the manager MUST construct:
- owner and group: the submitter's user SID;
- a DACL granting
JOB_ALL_ACCESSto the submitter, to SYSTEM (S-1-5-18) and to Administrators (S-1-5-32-544); - a SACL with one failure-audit ACE for Everyone (
S-1-1-0) overJOB_ALL_ACCESS, so that a refused command on the job is recorded by the access check (§4.7); - nothing else. In particular the job identity is granted nothing: a process cannot, by virtue of running as U, see or stop a job that runs as U. A submitter that wants the principal to see its own session says so.
A submit MAY supply security_descriptor, in SDDL, and the manager
MUST use it as given — it MUST NOT add its default entries to it. The
supplied descriptor MUST carry an owner and a DACL, or the submit is
INVALID_ARGUMENTS. A submitter that omits itself from the DACL it
supplies has locked itself out of its own job, and the manager MUST
NOT prevent that: the descriptor is the policy, and a policy the
manager second-guessed would not be one.
The descriptor is fixed at submission. This chapter defines no command to change it.
7.8.3 status #
Returns the job view. Requires JOB_QUERY.
7.8.4 wait #
Blocks until a condition holds, then returns the job view. Requires
JOB_QUERY.
for | Returns when |
|---|---|
terminal (default) | The job reaches a terminal state. |
ready | The job has sent READY=1, or reaches a terminal state, whichever first. |
for: ready on a readiness: none job MUST be answered
INVALID_STATE: there is nothing to wait for. On a job that is
already ready, or already terminal, the manager MUST answer at once.
A connection blocked on a wait is not idle (§7.3). The wait is
bounded by nothing but the job: a wait on a session job with no
timeout blocks until something stops it. A submitter that needs a
bounded wait polls status instead, or waits on the process handle it
was given.
If the job's record is dropped while a wait is blocked on it — which
can only happen after it became terminal and its grace period elapsed,
so only if the manager was unable to answer in time — the wait MUST
be answered UNKNOWN_JOB.
7.8.5 stop #
Asks the manager to end the job. Requires JOB_STOP.
The manager MUST send the termination signal to the job's process,
wait stop_timeout, and then kill everything remaining in the job's
containment. A job that has sent STOPPING=1 MUST NOT receive the
termination signal (§4.19); the deadline still applies.
wait defaults to true: the response is sent when the job is
terminal. With wait: false the manager MUST respond as soon as the
stop has been initiated, with the job view as it then is.
A stop on a job that is already terminal MUST return the job view
unchanged, with "status": "ok". It is not an error to stop something
that has stopped; it is a no-op, and the view is the honest answer. A
second stop on a job already being stopped MUST NOT restart the
deadline.
7.8.6 signal #
Sends one signal to the job's main process, and nothing else. Requires
JOB_SIGNAL.
signal MUST be an integer naming a signal the platform defines, or
the request is INVALID_ARGUMENTS. The manager MUST deliver it to
the process through the handle it obtained at fork, so that a recycled
PID can never be signalled by mistake, and MUST answer with the job
view.
A signal on a job that is not running MUST be answered
INVALID_STATE.
This is the raw mechanism, deliberately: SIGKILL through signal
kills the main process and leaves whatever it spawned, and produces a
failed job with exit_signal set and a null cause. A submitter
that wants the job ended uses stop.
7.8.7 What none of them do #
None of these commands moves a job backwards or starts it again. A terminal job stays terminal, its record is dropped after the grace period, and a submitter that wants the program to run again submits it again.
7.9 Descriptors and Output
Peios / Peios technical reference / PSPU / Job Submission
7.9.1 Descriptors into the job #
A submit MAY attach descriptors with SCM_RIGHTS. The manager MUST
give them to the job's process, in the order they were attached, in
exactly the form §4.20 gives stored descriptors back to a service:
- Placed consecutively from descriptor 3, with close-on-exec cleared.
LISTEN_FDSset to the count.LISTEN_FDNAMESset to the names fromdescriptors, colon-separated, in the same order.LISTEN_PIDset to the job process's own PID.
All four MUST be absent when nothing is passed.
descriptors names them. It MUST have exactly one entry per
descriptor to be injected — the attached descriptors, less the output
sink if output is true — or the submit is INVALID_ARGUMENTS. A
name MUST be non-empty and MUST NOT contain :, since : is the
separator in LISTEN_FDNAMES.
A submitter MAY attach nothing and send an empty descriptors; that
is the common case.
The manager MUST NOT inspect, validate or restrict what an attached descriptor refers to. What a job may do with a descriptor is decided by the rights cached on it, which the submitter established when it opened it.
7.9.2 The job's output #
A job's standard output and standard error MUST be captured by the manager and recorded exactly as a service's are — the manager's own design says where, and on Peios that is eventd, tagged with the job's identifier. This is unconditional: a submitter cannot turn it off, and cannot redirect the job's streams somewhere the manager does not see.
A submitter MAY additionally ask for a copy. With output: true, the
last attached descriptor is not injected into the job; the manager
adopts it as an output sink and writes to it every line it reads
from the job's streams, as it reads them.
The copy is best-effort, and the record is not:
- The manager MUST NOT block on the sink, and MUST NOT let a sink that is not being drained slow the job or the manager. It MUST put the sink in non-blocking mode.
- When a write to the sink would block, the manager MUST drop the line for the sink only, MUST still record it, and MUST count the drop. It MUST report that drops occurred — at least once per job, through its event stream — so a submitter that sees a gap can learn it caused one.
- The manager MUST close the sink when the job is terminal, after the last line has been written or dropped, and MUST close it at once if writing to it fails with anything other than a would-block condition.
What the manager writes to the sink is each line the job produced, followed by a newline, with no tagging: the submitter attached the sink knowing which job it was for. Lines from the two streams are interleaved in the order the manager read them. A submitter that needs to tell them apart, or needs every line, reads the record instead.
output: true with no attached descriptor is INVALID_ARGUMENTS.
7.10 Errors
Peios / Peios technical reference / PSPU / Job Submission
The code field of an error response MUST be one of these values. The
manager MUST NOT emit any other code on this channel, and a submitter
MUST treat a code it does not recognise as an unrecoverable error for
that request (§7.11).
| Code | Meaning | Closes? |
|---|---|---|
MALFORMED_REQUEST | The message is not a single well-formed JSON object. §7.4 | No |
REQUEST_TOO_LARGE | The message exceeds the configured maximum, or was truncated. §7.3 | Yes |
INVALID_COMMAND | command is absent, not a string, or names no command. | No |
INVALID_ARGUMENTS | A field is absent, malformed, or inconsistent with the attachments. §7.6, §7.9 | No |
UNKNOWN_JOB | job_id names nothing the manager holds — it never existed, or its retention has elapsed. §7.7 | No |
ACCESS_DENIED | The access check against the job's descriptor denied the right. §7.8 | No |
INVALID_STATE | The command is not valid for the job's state, or the manager is shutting down. | No |
QUOTA_EXCEEDED | The submitter already holds its maximum of live jobs. §7.3 | No |
BAD_TOKEN | The attached token cannot be a job identity. §7.5 | No |
INTERNAL_ERROR | The manager failed while executing the command. | No |
UNKNOWN_SERVICE, UNKNOWN_OPERATION and OPERATION_TIMEOUT are
not emitted here: nothing on this channel names a service or an
operation, and a wait has no timeout of its own to expire.
7.10.1 Distinctions a submitter can rely on #
UNKNOWN_JOB versus ACCESS_DENIED. A caller that may not query a
job receives ACCESS_DENIED for an identifier that exists, and
UNKNOWN_JOB for one that does not. This channel does not hide the
existence of a job from a caller that can name it: identifiers are
unguessable, and a caller that holds one was told it by something that
had it.
BAD_TOKEN is about the token, never about the identity. It says
the attached token is unusable — its level, its count, its rights —
and never that the submitter may not act as the principal it names.
That decision was the kernel's, made before the message arrived, and a
submitter that could not convey an identity never reaches the manager
with it.
QUOTA_EXCEEDED created nothing. No record, no event, no
consumption. A submitter MAY retry once one of its live jobs ends.
INVALID_STATE during shutdown. Once the manager is shutting
down it MUST reject submit with INVALID_STATE and MUST continue to
answer status, wait, stop and signal; a submitter watching its
jobs be stopped has a legitimate reason to keep looking, and a
submitter that wants a job stopped sooner than the shutdown will
manage has a legitimate reason to ask.
7.10.2 Codes are not extensible without a version #
The manager MUST NOT introduce a new code without the negotiation in §4.21, for the reason §4.10 gives: an unrecognised code silently turns a handled condition into an unhandled one.
7.11 Extension
Peios / Peios technical reference / PSPU / Job Submission
The jobs channel carries no version number, and is extended under the rules of §4.21. They are not repeated; what follows is how they apply here and what this chapter adds.
7.11.1 What may be added #
A request field, including a new definition field on submit. A
manager MUST ignore a field it does not recognise, so a submitter MAY
send one and MUST NOT depend on its effect.
A job view field. A client MUST ignore a field it does not recognise.
A command, and the error codes and enumerated values that appear only in responses to it, on the terms §4.21 gives: a client that never sends the command is never exposed to them.
7.11.2 What may not be added without a version #
A state, a cause, a progress unit, or a for value
outside §7.B. A command changing what an existing command does. A
change to the shape of the job view. A new error code on an
existing command.
7.11.3 What may never be added #
Two things are excluded from extension by any means, because their absence is a security property of the channel rather than a gap in it:
- An identity field. A
submitMUST NOT be able to name the principal the job runs as. Every job identity is one the kernel verified the submitter could convey (§7.5), and a field that named one would be a path around that verification. - A policy field. A
submitMUST NOT be able to ask for a restart, a dependency, a health check or a trigger. A job that needs policy is a service, and a service is defined where an administrator controls it.
A revision that wanted either would not be a revision of this channel.
7.12 Conformance
Peios / Peios technical reference / PSPU / Job Submission
7.12.1 A conforming manager #
The socket. Listens on a Unix sequenced-packet socket at the
well-known path, close-on-exec on the listener and every accepted
connection, and ensures the socket and each directory containing it
carry a Security Descriptor admitting exactly the principals meant to
submit. Performs no access check of its own before a submit (§7.3).
Messages. Emits and accepts exactly one compact JSON object per record, with no terminator. Receives every message with room for one token and the maximum number of descriptors, answers a truncated message or attachment list without acting on it, and closes every attached descriptor it does not hand on or adopt, on every path (§7.4).
Identity. Establishes the submitter from the kernel once, at
accept. Installs on a job exactly one of: the connecting process's own
primary token, opened through the kernel's peer process handle; or
the token the kernel attached to the submit, duplicated to a
primary. Refuses a token below Impersonation level, more than one
token, or one it cannot duplicate, with BAD_TOKEN. Accepts no
identity by name and no primary token by plain descriptor (§7.5).
Submission. Validates the definition as §7.6 lists and refuses with
INVALID_ARGUMENTS; does not check that the program exists. Checks
identity and quota before creating any record. Answers a submit when
the job has left created and not before or after, with the job view,
attaching the process handle when the job is running (§7.6).
The job view. Reports every job in the one shape of §7.7, with
every inapplicable field present and null, the state-dependent
population rules honoured, and the cause set only where the manager
decided the outcome. Holds a terminal record for the grace period and
answers a dropped or never-existent identifier UNKNOWN_JOB alike.
Management. Gives every job a descriptor — the default of §7.8 or
the one supplied — and checks every command against it with the
rights and mapping there, including commands from the submitter.
Implements status, wait, stop and signal with the semantics of
§7.8: a wait bounded only by the job, a stop that is a no-op on a
terminal job and does not restart a running deadline, a signal
delivered through the fork-time handle.
Descriptors and output. Injects attached descriptors from 3 upward
with LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID set; records the
job's output unconditionally; and, when asked, copies it to a
non-blocking sink, dropping for the sink only, counting and reporting
the drops, and closing the sink when the job ends (§7.9).
Errors and extension. Emits only the codes in §7.10, ignores unrecognised request fields, and adds nothing from §7.11's closed list — and never an identity or policy field — without a negotiated version.
7.12.2 A conforming submitter #
Connects as the identity it wants to own its jobs, and conveys any
other identity per message rather than by impersonating at connect.
Attaches at most one token, and sets its connection's level to
Delegation before attaching when the job needs it. Sends one compact
JSON object per record. Supplies every LISTEN_FDNAMES name it
attaches a descriptor for, and none containing :. Assumes the
manager owns every descriptor it attached. Treats an immediate close
with no response as a refusal. Does not parse message. Accepts null
for every nullable field and unrecognised fields in the job view.
Treats an unrecognised state, cause, unit or error code as an error
for that request rather than guessing. Reads state rather than
cause to decide whether a job succeeded. Uses stop to end a job
and signal only to signal it. Does not depend on a job's record
surviving its grace period.
7.12.3 A conforming job #
Nothing beyond §4.22's requirements of a service on the notification
channel. A job that sends progress does so in the form §4.19 defines.
A job that adopts descriptors checks LISTEN_PID first.
7.12.4 What conformance is not #
A manager that offers the control channel and not this one is still a conforming manager under §4. This chapter is the contract for one that offers both.
Appendix 7.A Limits and Defaults
Peios / Peios technical reference / PSPU / Job Submission
The values a Peios service manager uses. A manager MAY use different ones; where a value is configurable, it MUST be discoverable to an administrator through the same surface that sets it.
7.A.1 The socket #
| Bound | Value | Configurable | Defined in |
|---|---|---|---|
| Socket path | /run/services/peinit/jobs.sock | No | §7.3 |
| Socket descriptor, default | Connect for Authenticated Users (S-1-5-11); full control for SYSTEM and Administrators | Yes | §7.3 |
| Concurrent connections | 64 | Yes | §7.3 |
| Message size | 65536 bytes | Yes | §7.3 |
| Idle timeout | 30 seconds | Yes | §7.3 |
| Listen backlog | 32 | No | §7.3 |
| Live jobs per submitter | 64; SYSTEM exempt | Yes | §7.3 |
7.A.2 Attachments #
| Bound | Value | Defined in |
|---|---|---|
| Tokens per message | 1 | §7.4 |
| Descriptors per message | 64, including an output sink | §7.4 |
| First injected descriptor | 3 | §7.9 |
7.A.3 The definition #
| Field | Default | Defined in |
|---|---|---|
stop_timeout | 10 seconds | §7.6 |
readiness_timeout | 30 seconds | §7.6 |
timeout | 0, no limit | §7.6 |
working_directory | / | §7.6 |
7.A.4 Records #
| Bound | Value | Defined in |
|---|---|---|
| Terminal record retention | 60 seconds | §7.7 |
The retention value is the control channel's operation retention (§4.A), for the same reason: a client polling once a second must never find the outcome gone.
7.A.5 Composing with the control channel #
A job's status_text and progress come from datagrams on the
notification channel, whose bound is §4.A's 65536 bytes; the job view
is not bounded by the jobs channel's message size on the way out, so
a status a job can send is always one the manager can return.
The message-size bound applies to a submit and therefore to the sum
of its definition: a program with a very large environment or
arguments list is bounded here before it is bounded by execve.
Appendix 7.B Wire Vocabulary
Peios / Peios technical reference / PSPU / Job Submission
Every enumerated value that appears on the jobs channel. All are lower snake case. A manager MUST NOT emit a value outside these sets without the negotiation in §4.21, and a submitter MUST treat one it does not recognise as an error for that request rather than mapping it onto a value it knows.
Values shared with the control channel — response status, job type — are §4.B's and are not restated.
7.B.1 Command #
submit, status, wait, stop, signal
7.B.2 Job state #
| Value | Process? | Terminal? |
|---|---|---|
created | Not yet | No |
running | Yes | No |
completed | No | Yes |
failed | No | Yes |
abandoned | Yes, unkillably | Yes |
created is never reported on this channel (§7.6). It appears here
because the control channel's job-list may show it for a job whose
submit is still in flight.
7.B.3 Cause #
parent_setup_failure, pre_exec_failure, readiness_timeout,
timeout, explicit_stop, shutdown, process_unkillable
cause is null when the process ended of its own accord.
7.B.4 Readiness #
none, notify
Request-only, on submit. The job view reports readiness through
ready, not by echoing this.
7.B.5 Wait condition #
terminal, ready
Request-only, the for field of wait.
7.B.6 Progress unit #
bytes, items, percent
The vocabulary of PROGRESS_UNIT (§4.19), reported in the job view's
progress.unit. Null when the job sent none.
8.1 Scope and Roles
Peios / Peios technical reference / PSPU / Security Descriptor Editing
This chapter defines the editing channel: the interface by which a program has a person edit the Security Descriptor of an object the program holds, in a dialog drawn by another program, the editor.
The object may be of any kind that has a Security Descriptor — a file, a registry key, a service. The editor knows none of them. What it is told is what a person needs in order to edit the descriptor: what the object is called, what kind of thing it is, what its rights are called, and the descriptor itself. What it answers with is a descriptor.
8.1.1 Roles #
The requester is the program whose object it is. It starts the editor, reads the object's descriptor, tells the editor about it, and applies each descriptor the editor sends back. It holds the object, and whatever right changing the descriptor takes, throughout.
The editor draws the descriptor for a person to change, on the desktop the requester is on, and sends back what they apply. It holds no handle to the object and needs no right over it. It changes nothing itself.
The split is deliberate. The party that can change the object is the one that already holds it, and it applies exactly what it is sent, so an editor serves every kind of object without ever being trusted with one. A requester gains a full editor without containing one.
8.1.2 What this chapter leaves to others #
How the editor draws its dialog, and on which desktop, is the editor's own design and that desktop's. On a GXWI desktop the editor is an app like any other, found by the environment it inherits from the requester.
The layout of a Security Descriptor, of the access masks it carries and of its entries is PCDS §5's. This chapter carries descriptors and says nothing about their contents beyond which components are applied.
How a requester reads and applies a descriptor is its own concern, and depends on the kind of object. A requester SHOULD apply through the same handle it read through, so that what is applied reaches the object that was shown, wherever it has gone in the meantime.
8.2 Terminology
Peios / Peios technical reference / PSPU / Security Descriptor Editing
Security Descriptor, access mask, ACE, DACL, SACL, owner, group, and generic right are used with PCDS §5's meanings and are not redefined.
Requester. The program whose object's descriptor is being edited, and which started the editor. §8.1
Editor. The program that shows the descriptor to a person and sends back what they apply. §8.1
Line. One message on the editing channel: a single compact JSON object, encoded as UTF-8, followed by a line feed (U+000A). §8.3
Request. The first line the requester sends: everything the editor is told about the object. §8.4
Component. One of the five separately applicable parts of a
Security Descriptor: the owner, the group, the DACL, the SACL and the
label. The label is the SACL's mandatory integrity label alone, which
KACS applies apart from the rest of the SACL and with a different right
(KACS set-security). On the channel they are named owner, group,
dacl, sacl and label.
General right. A right the requester names for a person to grant or deny in one step, such as "Read" or "Full control", standing for an access mask that may have many bits. The requester decides which rights are general and what each stands for. §8.4
Applying. The requester writing components of a descriptor the editor sent onto the object. §8.5
8.3 The Channel
Peios / Peios technical reference / PSPU / Security Descriptor Editing
8.3.1 Starting the editor #
The requester MUST start the editor as a child process of its own, with the editor's standard input and standard output connected to it. It SHOULD start the editor with its own environment, which is how the editor finds the desktop to draw on.
The editor's standard error is not part of the channel. The editor MAY write diagnostics there for an administrator, and the requester MUST NOT read meaning into them.
8.3.2 Lines #
Each direction is a sequence of lines (§8.2): one compact JSON object, UTF-8, then a line feed. A line MUST NOT contain a line feed before its end; compact JSON never needs one.
- Requester to editor, on the editor's standard input: the request
(§8.4), first and exactly once, then one answer for each
apply(§8.5), andraise(below) whenever the person asks for the object again. - Editor to requester, on the editor's standard output:
applymessages (§8.5), and nothing else in this revision.
Every line carries a type member naming what it is, except the
request, which is identified by being first.
A party MUST ignore a line whose type it does not recognise, and a
member of an object it does not recognise (§8.7). A party MAY refuse a
line longer than 1 MiB; a request carrying a descriptor of the largest
size PCDS §5.1 permits is well inside it.
8.3.3 Asked for again #
A requester that the person asks to edit an object it already has an editor open on SHOULD NOT start a second editor for it: two dialogs on one object would each apply over the other. It SHOULD instead send
{"type":"raise"}
which needs no answer. An editor that receives it SHOULD bring its
dialog to the front and give it the keyboard, as far as the desktop
lets it. An editor that does not recognise it ignores it, as any line,
so a requester MUST NOT depend on the dialog coming forward. A requester
whose write of raise fails has learned the editor has gone, and MAY
start a new one.
8.3.4 Ending #
The channel ends when either party closes its end, and either party can tell when the other has.
The editor ends when the person closes or cancels the dialog, and after the person confirms once what it sent has been applied (§8.5). It ends by exiting, which closes its standard output; the requester reading end-of-file there is how it knows. An editor that ends sends nothing more and applies nothing, since it never could.
The requester ends, or gives up on the editor, by closing the editor's standard input. The editor MUST end promptly when it reads end-of-file there, whatever it is doing, and MUST NOT ask the person anything first: the requester can no longer apply what they would say. A requester that exits closes the editor's input whether it means to or not, so the editor goes with it.
The editor SHOULD also arrange to be signalled when its parent exits
(on Linux, PR_SET_PDEATHSIG), and end then. A process whose parent
exits is not ended by that, and an editor left showing a dialog for an
object nobody can apply to is worse than none.
The editor's exit status says nothing about what was applied. The requester knows that from its own answers (§8.5), and MUST NOT infer it from the status.
8.4 The Request
Peios / Peios technical reference / PSPU / Security Descriptor Editing
The request is the first line on the editor's standard input, and the
only one that has no type. It is one JSON object:
| Member | Type | Required | Meaning |
|---|---|---|---|
object | object | Yes | What the descriptor is the descriptor of. |
object.name | string | Yes | What the object is called where the person found it: notes.txt, not a full path, unless a path is how they know it. |
object.kind | string | Yes | What kind of thing it is, in words for the person: File, Folder, Service. |
object.container | boolean | No, false | Whether other objects inherit from it (PCDS §5.6). |
object.children | string | No, "all" | What a container holds, which is what the entries made for it are passed on to: all or containers (see Containers). Ignored unless container is true. |
object.parent | object | No | What the object is in, which its inherited entries come from (see The parent). |
object.parent.name | string | Yes, in parent | What the parent is called where the person knows it: /srv. |
object.parent.sd | string | No | The parent's descriptor, self-relative and in base64 as sd is. |
object.parts | array | No, empty | The object's parts that rules can be made for one at a time: object types (see Parts and kinds). |
object.parts[].guid | string | Yes | The object type, as a GUID in its usual text form: bf967aba-0de6-11d0-a285-00aa003049e2. |
object.parts[].name | string | Yes | What the part is called, for the person. |
object.parts[].kind | string | Yes | set, a set of properties; property; or right, an action. |
object.parts[].set | string | No | For a property, the GUID of the set it is in. |
object.kinds | array | No, empty | On a container of typed things, the kinds of thing it holds: inherited object types. |
object.kinds[].guid, .name | string | Yes | The inherited object type, and what that kind of thing is called. |
object.part_rights | array | No, empty | The rights a rule for a part can give, as rights gives the object's (see Parts and kinds). |
object.part_rights[].name, .mask | string, number | Yes | What the right is called, and its access mask. |
object.naming | object | No | How a part not listed is named (see Parts named by the person). |
object.naming.namespace | string | Yes, in naming | The UUID v5 namespace a part's object type is derived in, as a GUID in its usual text form. |
object.naming.noun | string | Yes, in naming | What one such part is called, for the person: field. |
object.naming.example | string | No | A name the person might give, to show how one is written: source.name. |
sd | string | Yes | The descriptor as it is now: a self-relative Security Descriptor (PCDS §5.1), in base64 with padding (RFC 4648 §4). |
read | array of strings | No | The components sd holds as they are on the object: some of owner, group, dacl, sacl and label (see The descriptor). Left out, every component sd has. |
rights | array | Yes | The object's rights by name, in the order a person should see them. |
rights[].name | string | Yes | What the right is called. |
rights[].mask | number | Yes | The access mask the right stands for, as an unsigned 32-bit integer. |
rights[].general | boolean | No, false | Whether it is a general right (§8.2). |
generic | object | Yes | What each generic right stands for on this kind of object (PCDS §5.3). |
generic.read, .write, .execute, .all | number | Yes | The access mask each generic right maps to. |
can | object | No | Which components the requester is able to change. |
can.dacl | boolean | No, true | Whether it can change the DACL. It is true when left out because, before this member, a requester always could. |
can.owner | boolean | No, false | Whether it can change the owner. |
can.audit | boolean | No, false | Whether it can change the SACL. |
can.label | boolean | No, false | Whether it can apply the label by itself (§8.5). |
can.propagate | boolean | No, false | Whether, on a container, it can push what was applied into what is already inside (§8.5, Pushing into what is inside). |
can.why | string | No | Why the requester cannot change what can says it cannot, as text for the person. |
8.4.1 The descriptor #
The requester SHOULD include the owner, the group and the DACL. It MAY
leave out the SACL: reading a SACL takes ACCESS_SYSTEM_SECURITY, which
a requester need not have. It SHOULD include the label where it can
read it, which takes only READ_CONTROL. A requester that could read the
label and not the rest of the SACL sends a SACL holding the label
alone, and MUST then say so in read, naming label and not sacl.
The editor MUST NOT take a SACL for the whole SACL unless read names
sacl or is left out. Where it is only the label, the editor MUST NOT
show it as an object with no auditing, claims or policies, since those
are not known.
A descriptor with no DACL component means one with no access list, which PCDS §5.1 treats as granting everyone everything. The editor MUST show it as such and not as an empty list, which grants nobody anything.
8.4.2 The parent #
object.parent names what the object's inherited entries come from,
for the person: "Inherited from /srv". An editor that offers to stop
inheriting MAY offer to inherit again, but KACS does not pass a
parent's entries down again by itself when protection is turned off
(PCDS §5.6): the editor works out what is passed down from
object.parent.sd, and SHOULD NOT offer to inherit again without it.
A requester SHOULD send the parent's descriptor where it can read it.
8.4.3 Parts and kinds #
object.parts lists the object types (PCDS §5.4) a rule can be made
for, as an account's sign-in details or the action of resetting its
password. object.kinds lists, for a container of typed things, the
kinds of thing it holds, which an entry can be passed on to alone. An
editor MAY offer rules for parts and kinds only where they are listed,
and MUST keep as it found it an entry naming a GUID it is not told of.
object.part_rights names what a rule for a part can give. Left out,
an editor offers what a directory's parts take: reading and writing a
property, and using an action. A requester whose parts take other rights
MUST send them. eventd's fields, for example, take only its Read.
8.4.3.1 Parts named by the person #
Some objects have more parts than can be listed, each one's object type
derived from its name. eventd's fields are an example: a payload field
needs no registration, and its GUID is derived from its name (eventd TRM
§7.3). object.naming tells the editor how such a part is named. Its
object type is the UUID v5 (RFC 9562 §5.5) of the name, as UTF-8, in
naming.namespace, and it is a property.
An editor MAY then let the person name a part beside those listed. It
MUST derive the object type exactly so, and SHOULD show the part by the
name given for as long as the dialog is open. A requester SHOULD still
list in parts the ones it knows, so that an existing rule for one is
shown by name.
8.4.4 The rights #
rights names every right the requester wants a person to see.
General rights SHOULD come first, from the one granting the most to the
one granting the least, so that a right that includes another is above
it. The masks of two rights MAY overlap, and typically do: "Modify"
includes "Read".
An editor that shows general rights as boxes to tick SHOULD show as a whole what a mask it cannot express in them — a mask with bits outside every general right it contains, an entry of a type it does not edit, or inheritance flags other than its own — rather than leave it unshown, and SHOULD keep such an entry as it found it.
generic lets the editor read an entry that grants generic rights
(PCDS §5.3) as the rights they stand for. The requester MUST give the
mapping its kind of object actually uses.
8.4.5 Containers #
On a container, the entries an editor makes apply to the container and
are passed on to what it holds. object.children says what that is,
and so which inheritance flags (PCDS §5.6) those entries carry:
| Value | Holds | Flags of an entry made here |
|---|---|---|
all | Containers and other objects, as a folder holds folders and files. | Object inherit and container inherit. |
containers | Containers alone, as a registry key holds keys and nothing else. | Container inherit. |
An entry with other inheritance flags is one the editor cannot express in its general rights (above), and is shown as a whole and kept.
8.4.6 What the requester can change #
can says what the requester is able to change. It MUST NOT say it
can change a component it cannot. can.label is true only where it can
apply the label by itself, which takes WRITE_OWNER and, for a level
above the caller's own, SeRelabelPrivilege (KACS set-security). It SHOULD find out by asking the
system, for example by an AccessCheck of the person's token against the
descriptor, or by opening the object for the rights that changing it
takes. It SHOULD NOT infer it from the person's group memberships.
can.propagate is true only on a container whose requester holds what
is inside it and can walk it: a file explorer for a folder, a registry
editor for a key. It says nothing about whether each item inside can be
changed, which is found out item by item as the walk goes (§8.5).
A requester that cannot change the DACL still sends it, so that the
person can read it. The editor MUST then show the DACL without offering
to change it, and SHOULD say why, with can.why if it was given. An
editor whose requester can change nothing SHOULD offer only to close.
8.4.7 Validation #
An editor that cannot use the request — one that is not a JSON object,
lacks a required member, or carries an sd that is not a Security
Descriptor — MUST NOT show a dialog. It SHOULD write why to its
standard error, and MUST end (§8.3).
8.5 Applying
Peios / Peios technical reference / PSPU / Security Descriptor Editing
8.5.1 apply #
When the person asks for what they have done to take effect — Apply, or OK — the editor sends:
| Member | Type | Meaning |
|---|---|---|
type | "apply" | |
sd | string | The whole descriptor as the person now has it, self-relative and in base64 as in the request (§8.4). |
parts | array of strings | The components of sd that changed since the request, or since the last apply the requester answered applied: some of owner, group, dacl, sacl, label. |
propagate | boolean | Optional, false when left out. Once parts is applied, push what the object now passes down into what is already inside it (see Pushing into what is inside). |
The editor MUST NOT send an apply as the person changes things, only
when they ask for it. A descriptor applied part way through a change
can take away the access the person needs to finish it: denying
Everyone before granting oneself, say.
The editor MUST NOT name dacl in parts unless the request's
can.dacl was true or left out, nor owner or group unless
can.owner was true, nor sacl unless can.audit was and the request
held the whole SACL, nor label unless can.label was. It MUST NOT
send propagate true unless can.propagate was, nor without the
person having chosen it for this apply. It MUST NOT send an apply
while one is unanswered, and SHOULD NOT send one whose parts is
empty. It MAY send one whose parts names components that have not
changed since they were applied, with propagate true, to push them
into what is inside again (after a walk was stopped or some of it
failed): applying them again changes nothing.
8.5.2 The label #
The label is in the SACL, and sacl carries it: a changed label goes
with a changed SACL. parts MUST NOT name both sacl and label,
which KACS refuses to apply together (KACS set-security). label names
the label alone, where the rest of the SACL has not changed or cannot
be applied: the SACL of sd then holds the label that is to be the
object's, or none to remove it, and the requester applies it as a label
(LABEL_SECURITY_INFORMATION), which keeps the rest of the object's SACL
as it is. A requester that keeps a descriptor whole, as a registry value,
puts the label into the SACL it has, in place of the label there.
8.5.3 The answer #
The requester MUST answer every apply with exactly one line, in the
order they came:
| Line | Meaning |
|---|---|
{"type":"applied"} | Every component in parts was applied. Where propagate was true, it carries how pushing into what is inside went (below). |
{"type":"failed","why":"…"} | None was, or not all, and why says why, as text for the person. Nothing was pushed into what is inside. |
The requester MUST apply the components named in parts, and MUST NOT
apply any other component of sd. The rest is as the requester sent
it, or as the editor could not read it, and writing it back would at
best change nothing and at worst write over what changed meanwhile.
The requester SHOULD apply the named components in one operation where
the kind of object allows it. Where it cannot, and some were applied
and some were not, it MUST answer failed and SHOULD say which were
applied.
why is shown to the person as it stands. It SHOULD say what was in
the way in words they can act on — "you are not allowed to" — rather
than an error code.
8.5.4 Pushing into what is inside #
Inheritance is eager (PCDS §5.6): what a container passes down reaches
the things made inside it afterwards, and the things already there keep
what they were given. Where the person changes what a container passes
down, the editor SHOULD ask whether to update what is already inside
too, and, where they say yes, sends the apply with propagate true.
A requester sent propagate true first applies parts as above. If it
answers failed, it pushes nothing. Otherwise, before it answers, it
re-propagates everything inside the object as PCDS §5.6 sets out
(Re-propagation), for the lists parts names: the DACL for dacl, the
SACL for sacl, and for label the SACL as far as it holds labels,
read and applied as labels. In particular it MUST:
- go parents first, re-propagating each item from its parent as just rewritten;
- leave a list an item protects exactly as it is, and still re-propagate what is inside that item from it as it is;
- resolve CREATOR OWNER and CREATOR GROUP against each item's own owner and group, and map generic rights through the generic mapping of the kind of thing it holds;
- change no component of an item but the lists it re-propagates;
- follow no link out of the tree;
- carry on past an item it cannot read or change, noting it, and leave what is inside that item as it is.
The editor sends nothing for the items inside: what each gets is worked out by the requester, from the object as it now is.
While it walks, the requester MAY send:
| Line | Meaning |
|---|---|
{"type":"progress","done":n,"at":"…"} | done items have been re-propagated, the last being at, by what the person knows it as. |
It needs no answer. A requester SHOULD send one now and then on a long walk, and SHOULD NOT send them more often than a person can read.
The editor MAY send, while it waits for the answer to an apply with
propagate true:
| Line | Meaning |
|---|---|
{"type":"stop"} | Stop the walk after the item in hand. |
It needs no answer of its own: the requester stops, and answers the
apply as it would have at the end. What was re-propagated stays so.
End-of-file on the requester's input from the editor means the same.
The applied answer to an apply with propagate true carries:
| Member | Type | Meaning |
|---|---|---|
done | number | How many items inside were re-propagated, those that protect every list included. Left out, none. |
failed | array | The items that could not be, each {"name":"…","why":"…"}: what the person knows it as, and why, as text for the person. Left out, none. |
stopped | boolean | Whether the walk was stopped before the end. Left out, it was not. |
Each of these concerns the items inside, not the object: applied
still means the object's parts were all applied. The editor SHOULD
show how far the walk got, a way to stop it, and afterwards what could
not be done, and SHOULD NOT end on an apply-and-close whose walk failed
for some items or was stopped, so that the person sees it.
8.5.5 After the answer #
On applied, the editor takes what it sent as the descriptor it is
editing from then on: a later apply names only what changed after.
If the person had asked to apply and close, the editor ends (§8.3).
On failed, the editor MUST show the person why and MUST NOT end
because of it: what they did is still there to be put right or
abandoned. What it has not had answered applied it still counts as
changed.
8.6 Extension
Peios / Peios technical reference / PSPU / Security Descriptor Editing
The editing channel carries no version number. It grows by addition, under these rules.
8.6.1 What may be added #
A member of the request, or of any line. A party MUST ignore a member it does not recognise, so a requester MAY send one and MUST NOT depend on its effect.
A line type, in either direction, that the other party may ignore
and still conform: a party MUST ignore a type it does not recognise
(§8.3). A line that needs an answer MUST NOT be added this way, since
an editor waiting on an answer from a requester that ignored the
question would wait for ever.
8.6.2 What may not be added without a version #
A change to the meaning of an existing member or line. A component name
beyond the five of §8.2. An answer to apply other than the two of
§8.5.
The fifth, label, was added without a version because nothing can
send it to a requester that does not know it: an editor names it only
for a requester that said can.label, which an older requester never
says.
Pushing into what is inside was added the same way. An editor sends
propagate only to a requester that said can.propagate. progress
and stop are lines that need no answer. What applied carries about
the walk is members of it, and applied still means only that the
object's parts were applied.
object.part_rights and object.naming were added as optional members
of the request. An editor that does not know naming offers only the
parts listed. An editor that does not know part_rights offers a
directory's rights for a part, which may not be rights the object's
parts take. For eventd's fields they aren't, so such a rule gives
nothing.
raise (§8.3) was added as a line that needs no answer. An older
editor ignores it, and its dialog stays where it is.
8.6.3 What may never be added #
A way for the editor to apply. Nothing on the channel may let the editor change the object itself, or ask the requester to apply anything but a descriptor the person asked to apply. That the editor holds nothing and is trusted with nothing is what lets a requester open it on any object, and the requester applying exactly what it is sent is the whole of the contract between them.
Pushing into what is inside keeps to this. The editor sends no
descriptor for anything but the object, and propagate asks only for
what PCDS §5.6 says re-propagation of the object's descriptor is, which
the requester works out itself from the object as it applied it. The
editor cannot choose what any item inside gets.
8.7 Conformance
Peios / Peios technical reference / PSPU / Security Descriptor Editing
8.7.1 A conforming requester #
Starting. Starts the editor as its own child with the editor's standard input and output connected to it, and sends the request as the first line (§8.3).
The request. Sends every required member, a self-relative
descriptor in padded base64, the general rights from the most to the
least, and the generic mapping its kind of object actually uses. On a
container, says in object.children what it holds. Says in can only
what it can in fact change, having asked the system. Says in read
which components it sent where that is not everything sd has, as a
SACL holding only the label (§8.4).
Applying. Answers every apply with exactly one line, in order.
Applies the components parts names and no others, and the label as a
label, keeping the rest of the SACL. Answers applied only when all of
them were, and otherwise failed, with a reason for the person (§8.5).
Pushing into what is inside. Says can.propagate only on a
container it can walk. Asked to, after applying, re-propagates
everything inside as PCDS §5.6 sets out, for the lists parts names:
parents first, protected lists left, nothing changed but those lists, no
link followed, past an item it cannot change. Stops after the item in
hand on stop or end-of-file, and answers applied with how many were
done, which could not be and why, and whether it was stopped (§8.5).
Lines. Ignores a line type and a member it does not recognise (§8.3). Infers nothing from the editor's exit status or standard error (§8.3).
8.7.2 A conforming editor #
Starting. Shows no dialog for a request it cannot use, and ends (§8.4).
Showing. Shows a descriptor without a DACL as granting everyone
everything, never as an empty list. Shows what it cannot express in the
general rights as a whole, and keeps such entries as it found them.
Gives the entries it makes on a container the inheritance flags of what
the container holds. Shows a DACL the requester cannot change without
offering to change it. Takes a SACL for the whole SACL only where
read says it is (§8.4).
Applying. Sends an apply only when the person asks, with the
whole descriptor and the components that changed, never dacl,
owner, group, sacl or label it was not told it could, never
sacl and label together, and never while one is unanswered.
Shows a failed answer's reason and stays (§8.5). Sends propagate
only to a requester that said can.propagate, and only where the person
chose it (§8.5).
Changing nothing. Holds no handle to the object and changes nothing itself (§8.1, §8.6).
Ending. Ends when the person closes or cancels, after an applied
answer to an apply-and-close, and promptly on end-of-file on its
standard input, without asking the person anything. SHOULD also end
when its parent exits (§8.3).
Appendix 8.A Message Reference
Peios / Peios technical reference / PSPU / Security Descriptor Editing
| Direction | Line | Defined in |
|---|---|---|
| Requester → editor | The request (first line, no type) | §8.4 |
| Editor → requester | {"type":"apply","sd":…,"parts":[…],"propagate":…} | §8.5 |
| Requester → editor | {"type":"progress","done":…,"at":…} (while pushing into what is inside; no answer) | §8.5 |
| Editor → requester | {"type":"stop"} (while pushing into what is inside; no answer) | §8.5 |
| Requester → editor | {"type":"applied","done":…,"failed":[…],"stopped":…} (the last three only after pushing) | §8.5 |
| Requester → editor | {"type":"failed","why":…} | §8.5 |
| Requester → editor | {"type":"raise"} (the object asked for again; no answer) | §8.3 |
8.A.1 An example #
A file explorer opens the permissions of a file. The person adds Everyone with Read and presses Apply, which fails, then changes their mind and closes the dialog. Lines are shown on several lines here for reading. On the channel each is one line, and the descriptors are shortened.
→ {"object":{"name":"notes.txt","kind":"File","container":false,
"parent":{"name":"/home/dana","sd":"AQAEgBQAAAAkAAAA…"}},
"sd":"AQAEgBQAAAAkAAAAAAAAADQAAAAB…",
"read":["owner","group","dacl","label"],
"rights":[{"name":"Full control","mask":2032127,"general":true},
{"name":"Read","mask":1179785,"general":true}],
"generic":{"read":1179785,"write":1179926,"execute":1179808,"all":2032127},
"can":{"owner":true,"audit":false,"label":true}}
← {"type":"apply","sd":"AQAEgBQAAAAkAAAAAAAAADQAAAAC…","parts":["dacl"]}
→ {"type":"failed","why":"you are not allowed to"}
The editor shows why and stays open. The person closes it, and the requester reads end-of-file on the editor's output.
The requester could read the label but not the rest of the SACL, so
read names label and not sacl, and the SACL in sd holds the
label alone. Had the person raised the label, the editor would have
sent "parts":["label"].
8.A.2 Pushing into what is inside #
The same explorer opens a folder's permissions, saying it can push into what is inside. The person gives Finance Modify on the folder and everything in it, presses OK, says yes to updating what is already inside, and stops partway.
→ {"object":{"name":"finance","kind":"Folder","container":true}, …,
"can":{"owner":true,"audit":true,"label":true,"propagate":true}}
← {"type":"apply","sd":"AQAEnBQAAAAkAAAA…","parts":["dacl"],"propagate":true}
→ {"type":"progress","done":120,"at":"/srv/finance/2025/q3.xlsx"}
→ {"type":"progress","done":480,"at":"/srv/finance/2026/budget"}
← {"type":"stop"}
→ {"type":"applied","done":512,
"failed":[{"name":"/srv/finance/archive","why":"you are not allowed to"}],
"stopped":true}
The folder's DACL was applied, and 512 items inside were updated before
the walk stopped. The editor shows that, and what could not be done, and
stays open though OK was pressed. The person can push the rest with
another apply of the same parts.
9.1 Scope and Roles
Peios / Peios technical reference / PSPU / File Choosing
This chapter defines the choosing channel: the interface by which a program has a person choose a file, either an existing one to read or a place and a name to write to, in a dialog drawn by another program, the chooser.
The chooser knows nothing of what the file is for. It is told what to call the dialog, what the file is for in words, where to start, and what kinds of file to offer. What it answers with is a path, or that the person chose nothing.
9.1.1 Roles #
The requester is the program that wants a file. It starts the chooser, says what it wants, and, given a path, opens, reads, creates or writes the file itself.
The chooser shows the person's folders and lets them pick a file or type a name, and answers with the path. It runs as the same person as the requester, and, as a small file manager, lets them make a folder and rename and delete as themselves. It never opens the chosen file, and hands the requester nothing but a path.
The split mirrors §8's. The party that will use the file is the one that opens it, with its own token, so the chooser is trusted with nothing it could misuse, and serves any requester without knowing what files are for.
9.1.2 What this chapter leaves to others #
How the chooser draws its dialog, and on which desktop, is the chooser's own design and that desktop's. On a GXWI desktop the chooser is an app like any other, found by the environment it inherits from the requester.
What the person may do with files and folders is KACS's to decide, as it is for any program of theirs. This chapter says only that the chooser asks the system rather than guessing (§9.6).
What a requester does with the file, and in what format, is its own concern.
9.2 Terminology
Peios / Peios technical reference / PSPU / File Choosing
Requester. The program that wants a file chosen, and which started the chooser. §9.1
Chooser. The program that shows the person's folders and answers with the file they choose. §9.1
Line. One message on the choosing channel: a single compact JSON object, encoded as UTF-8, followed by a line feed (U+000A). §9.3
Request. The first line the requester sends: everything the chooser is told about what is wanted. §9.4
Answer. The one line the chooser sends: the path chosen, or that nothing was. §9.5
Mode. Whether an existing file is to be opened (open) or a place
and a name to be saved to (save). §9.4
Filter. A kind of file the requester offers, named for the person and given as a list of extensions. §9.4
9.3 The Channel
Peios / Peios technical reference / PSPU / File Choosing
9.3.1 Starting the chooser #
The requester MUST start the chooser as a child process of its own, with the chooser's standard input and standard output connected to it. It SHOULD start the chooser with its own environment, which is how the chooser finds the desktop to draw on, and the person's home folder.
The chooser's standard error is not part of the channel. The chooser MAY write diagnostics there for an administrator, and the requester MUST NOT read meaning into them.
9.3.2 Lines #
Each direction is a sequence of lines (§9.2): one compact JSON object, UTF-8, then a line feed. A line MUST NOT contain a line feed before its end.
- Requester to chooser, on the chooser's standard input: the request (§9.4), first and exactly once, and nothing else in this revision.
- Chooser to requester, on the chooser's standard output: the answer (§9.5), at most once, and nothing else in this revision.
The answer carries a type member naming what it is. The request is
identified by being first.
A party MUST ignore a line whose type it does not recognise, and a
member of an object it does not recognise (§9.7). A party MAY refuse a
line longer than 1 MiB.
9.3.3 Ending #
The chooser ends once it has answered, and when the person closes or
cancels the dialog, which it answers as cancelled first. It ends by
exiting, which closes its standard output.
The requester ends, or gives up on the chooser, by closing the
chooser's standard input. The chooser MUST end promptly when it reads
end-of-file there, without answering and without asking the person
anything: nobody is left to use the answer. A requester that exits
closes the chooser's input whether it means to or not, so the chooser
goes with it. The chooser SHOULD also arrange to be signalled when its
parent exits (on Linux, PR_SET_PDEATHSIG), and end then.
A requester that reads end-of-file on the chooser's output with no
answer MUST treat it as cancelled. It MUST NOT infer anything from the
chooser's exit status.
9.4 The Request
Peios / Peios technical reference / PSPU / File Choosing
The request is a JSON object, sent once, as the first line.
| Member | Type | Required | Meaning |
|---|---|---|---|
mode | string | yes | open for an existing file to read; save for a place and a name to write to |
title | string | yes | What the dialog is called, for the person: "Export a key" |
purpose | string | no | What the file is for, in a sentence, for the person to read |
folder | string | no | An absolute path to the folder to start in |
name | string | no | The name to suggest, in save mode: "Services.json" |
filters | array | no | The kinds of file to offer, in order, each an object below |
Each filter is an object with:
| Member | Type | Meaning |
|---|---|---|
name | string | What the kind is called, for the person: "Registry documents" |
extensions | array of strings | The extensions it covers, without the dot: ["json"] |
9.4.1 What the chooser does with it #
The chooser MUST start in folder if it is given, absolute, and a folder
the person may list, and otherwise in the person's home folder.
If filters is given, the chooser MUST offer them in the order given,
with the first chosen at first, and MAY offer every file as well. In
open mode it SHOULD show only folders and the files of the kind
chosen. A filter matches a file by its extension, without regard to
case.
In save mode the chooser MUST offer name, if given, as the name to
save as, and the person MAY change it. If the person types a name
without an extension while a filter is chosen, the chooser SHOULD add
the filter's first extension.
The requester MUST NOT rely on the person choosing a file of a kind it offered: the person may choose any file. A requester that needs a kind checks what it opens.
A chooser that receives a request it cannot use, because a required
member is missing or mode is unknown, MUST NOT show a dialog, and ends
without answering.
9.5 The Answer
Peios / Peios technical reference / PSPU / File Choosing
The chooser sends at most one answer, and ends after sending it.
| Line | Meaning |
|---|---|
{"type":"chosen","path":…} | The person chose the file at path, an absolute path |
{"type":"cancelled"} | The person chose nothing: they cancelled or closed the dialog |
9.5.1 What a path promises #
In open mode, the path names a file that existed when the person chose
it. The requester opens it, and MUST handle its having gone, or changed,
since.
In save mode, the path names a place the person chose to write to, in
a folder that existed when they chose it. If a file was there, the
person has already said to save over it: the chooser MUST ask before it
answers with the path of a file that exists. The requester creates or
replaces the file, and MUST handle being refused, since what the person
may do can change, and the chooser could not open the file to find out.
The chooser MUST NOT open, create or change the chosen file to choose it, and MUST NOT answer with a path it has not shown or the person has not typed. In neither mode does a path grant anything: the requester uses it as the person, with its own token.
9.6 The Chooser
Peios / Peios technical reference / PSPU / File Choosing
The chooser shows one folder at a time, and lets the person move among folders. Beyond choosing, it MAY let them make a folder, and rename and delete files and folders: a small file manager, so that a person saving a file can make somewhere to put it.
9.6.1 As the person #
The chooser runs as the same person as the requester, started as its child. Everything it does to files and folders is that person's act, which KACS checks as for any program of theirs. The chooser MUST NOT hold or use any authority beyond theirs.
9.6.2 Asking before offering #
The chooser MUST NOT guess what the person may do from who they are. Before it offers an act, it SHOULD find out whether the person may do it, by asking the system: an AccessCheck of the person's token against the descriptor of the folder or the file. What the person may not do it SHOULD show disabled, with the reason in words, rather than hide it or offer it to fail. Where the descriptor cannot be read, it MAY offer the act and let the system decide.
A folder the person may not list MUST be shown as such, with the reason, and not as an empty folder.
9.6.3 Deleting #
The chooser MUST ask the person before it deletes anything, and say when a folder will be deleted with everything in it.
9.7 Extension
Peios / Peios technical reference / PSPU / File Choosing
The choosing channel carries no version number. It grows by addition, under these rules.
9.7.1 What may be added #
A member of the request or of the answer. A party MUST ignore a member it does not recognise, so a requester MAY send one and MUST NOT depend on its effect.
A line type from the chooser that the requester may ignore and
still conform: a requester MUST ignore a type it does not recognise
(§9.3), and goes on waiting for the answer.
9.7.2 What may not be added without a version #
A change to the meaning of an existing member or line. A mode beyond
open and save. A second answer, or a line from the requester after
the request.
9.7.3 What may never be added #
A way for the chooser to hand over a file. Nothing on the channel may carry an open file, its contents, or anything but a path from the chooser to the requester. The requester opening what it is pointed at, as the person, is what lets any program use a chooser it does not trust.
9.8 Conformance
Peios / Peios technical reference / PSPU / File Choosing
9.8.1 A conforming requester #
Starting. Starts the chooser as its own child with the chooser's standard input and output connected to it, and sends the request as the first line (§9.3).
The request. Sends mode and title, a folder that is absolute
if any, and filters with extensions without the dot (§9.4).
The answer. Uses a chosen path as the person, opening the file
itself, and handles its having gone or the act being refused. Treats
end-of-file without an answer as cancelled (§9.3, §9.5). Infers
nothing from the chooser's exit status or standard error, and does not
rely on the person choosing a file of a kind offered (§9.4).
Lines. Ignores a line type and a member it does not recognise (§9.3).
9.8.2 A conforming chooser #
Starting. Shows no dialog for a request it cannot use, and ends (§9.4). Starts in the folder given, or the person's home (§9.4).
Choosing. Offers the filters in order, the first chosen (§9.4). In
save mode, asks before answering with a file that exists (§9.5).
Never opens, creates or changes the chosen file to choose it (§9.5).
The answer. Sends at most one, a chosen with an absolute path or a
cancelled, and ends. Answers cancelled when the person closes or
cancels the dialog (§9.3, §9.5).
Ending. Ends promptly, without answering or asking, when its standard input closes (§9.3).
As the person. Acts on files and folders only as the person, and holds nothing beyond their authority. Asks the system what they may do before offering it, and shows what they may not, with the reason. Shows a folder they may not list as such. Asks before deleting (§9.6).
Lines. Ignores a member it does not recognise (§9.3).
Appendix 9.A Message Reference
Peios / Peios technical reference / PSPU / File Choosing
| Direction | Line | Defined in |
|---|---|---|
| Requester → chooser | The request (first line, no type) | §9.4 |
| Chooser → requester | {"type":"chosen","path":…} | §9.5 |
| Chooser → requester | {"type":"cancelled"} | §9.5 |
9.A.1 An example #
A registry editor exports a key. The person goes into a folder, makes a folder called Backups inside it, opens it, keeps the suggested name and saves. Lines are shown on several lines here for reading. On the channel each is one line.
→ {"mode":"save","title":"Export Services",
"purpose":"The key and everything under it is written to a registry document.",
"folder":"/home/dana","name":"Services.json",
"filters":[{"name":"Registry documents","extensions":["json"]}]}
← {"type":"chosen","path":"/home/dana/Backups/Services.json"}
The chooser ends, and the registry editor writes the file.
10.1 Scope and Roles
Peios / Peios technical reference / PSPU / Local Principal Administration
This chapter specifies PLPS, the protocol on which a program administers a local principal store: the users and groups a machine holds for itself, their memberships, their credentials and their claims. It is spoken to the daemon that owns the store, never to the store's file.
Two roles participate.
The store daemon owns the store. It is the store's only reader and
only writer, it decides who may administer it, and it applies each
request or refuses it. On Peios it is lpsd, which is also the machine's
local principal source on PSI (§2): the store it administers here is the
one it answers for there.
The administrator client is a program that asks: a command-line tool
such as lps, a desktop app such as Principals Manager, or a
provisioning tool. It holds no state of its own and no privilege of its
own; everything it does is a request the store daemon decides whether to
honour. Any program MAY be an administrator client, and a third party
writing one is the case this chapter is written for.
10.1.1 Why a daemon, and not the file #
The store holds every local principal's credential verifier, so its descriptor admits the store daemon and nothing else. A tool that edited the file would need that descriptor widened to whatever it runs as, undoing the one thing it exists to do. Going through the daemon keeps one writer, makes "an administrator may do this" a check on the caller's token rather than a file permission, and lets the daemon validate every change before anything is written.
This chapter covers:
- the channel: where it is, who may connect, and the one exchange each connection carries (§10.3)
- message framing, shared with PGSS Logon (§10.4)
- the requests: principals (§10.5), groups and membership (§10.6), claims (§10.7), and SSH keys and credential policy (§10.8)
- the replies, and how a request is refused (§10.9)
- the obligations binding on each role (§10.10)
- the self socket, on which any principal reads their own account and sets their own display name (§10.11)
This chapter does not cover:
- How the store is kept on disk, or how a credential verifier is derived — the store daemon's own design.
- Signing in, and a principal changing their own password or adding or removing their own SSH keys. Those are PGSS Logon (PGSS §2.20 and PGSS §2.23), and reach the store through PSI (§2.21, §2.23).
- Looking other principals up. Anyone may ask who a name or a SID is, and who is in a group, on the authority's identity socket (PGSS §2.14); a client that only reads about principals in general SHOULD read there rather than on the admin socket, since that socket answers everyone. A principal reading their own account, including what the identity socket does not carry — their credential policy and SSH keys — reads it on the self socket (§10.11).
- What policy applies to a principal at sign-in, such as its privileges. That is the authority's (PGSS §2.13).
10.2 Terminology
Peios / Peios technical reference / PSPU / Local Principal Administration
Store. The set of local principals and local groups one store daemon holds, with their memberships, credentials, claims and SSH keys.
Principal. A user or service account the store holds. It has a name, unique in the store and matched without regard to case, and a RID (relative identifier): the last sub-authority of its SID. A RID is never reissued, so a deleted principal's SID never names anyone else.
Domain SID. The SID every principal and local group of the store is
under, S-1-5-21-A-B-C. A principal's SID is the domain SID followed by
its RID.
Local group. A group the store holds, named and numbered as a principal is, from the same RID space.
Well-known group. A group whose SID is fixed by the system rather
than issued by any store: Everyone, Authenticated Users, and the
BUILTIN groups such as Administrators. The store holds none of them as
objects, but it records memberships of the BUILTIN groups (§2.19).
Group name, as written. Where a request names a group (§10.6), the
store daemon resolves it, never the client: a well-known group by its
name (Administrators, with or without BUILTIN\), a local group by its
name, or any group by its SID written out (S-1-5-32-544). A client
MUST pass what the person wrote, and MUST NOT resolve it itself; a client
with its own table of well-known names would be a second implementation
of the machine's identity scheme, free to disagree with the first.
Effective Unix ID. The POSIX identifier a principal or group projects to: the authority's base for the store's range plus the store's relative number (§2.20). Replies here carry the effective number, so that what an administrator is shown is what the principal will appear as. Zero means the store daemon cannot say.
Credential policy. Which credentials a principal may sign in with (§10.8): a password, an SSH public key, either, none required, or none accepted.
10.3 The Channel
Peios / Peios technical reference / PSPU / Local Principal Administration
10.3.1 The socket #
The store daemon listens on a Unix stream socket. On Peios it is
/run/lpsd/admin.sock.
The socket's descriptor SHOULD admit exactly those the store daemon
would (§10.3, Who may administer), so that a caller it would refuse is
refused at connect rather than after sending a request. That descriptor
is load-bearing rather than a convenience: the store daemon reads the
caller's token from the connection, and a socket open to everyone would
let anyone hold a connection open against its timeouts.
A client MUST treat a connect refused for permission as the store
daemon refusing it, exactly as Denied (§10.9).
10.3.2 One request a connection #
Each connection carries one exchange: the client sends one request, the store daemon sends one reply, and the connection is finished. There is no pipelining, no conversation identifier and nothing to demultiplex. The store daemon MUST close the connection after replying, and MUST NOT read a second request from it.
A client MUST open a connection for each request. It MUST NOT hold one open waiting to use it later: the store daemon closes a connection that sends nothing (below).
Every request is answered exactly once, with its own reply
(§10.A) or with Failed (§10.9). A client MUST treat a connection closed
without a reply as the request's outcome being unknown: a change MAY
have been applied. It SHOULD read the store again before telling a person
the change failed.
10.3.3 Who may administer #
The store daemon decides who may administer the store from the caller's
token, read from the connection, and it MUST decide before reading the
request. On Peios a caller may administer the store if its token's user
is SYSTEM, or if it carries BUILTIN\Administrators enabled. A
group present but deny-only grants nothing, and MUST NOT be treated as
membership.
SYSTEM is admitted because a machine's first account is created by a
boot-time service, before any person exists to be an administrator.
There is nothing finer. A caller that may administer the store may make every request on this socket; one that may not, may make none. In particular a principal may not change their own profile here. What a principal may do about their own account is served elsewhere: their display name and a view of their account on the self socket (§10.11), and their password and SSH keys through PGSS Logon (PGSS §2.20 and PGSS §2.23).
A store daemon refusing a caller MUST reply Failed with Denied and
then close the connection.
10.3.4 Timeouts #
A store daemon SHOULD bound how long it waits for the request and how long it spends sending the reply. On Peios each is five seconds.
A client SHOULD wait for a reply far longer than that. A request that
sets a password makes the store daemon derive a verifier, and a store
daemon that also answers PSI may serve a sign-in first; on Peios lps
waits thirty seconds.
10.3.5 Credentials on the wire #
Add and SetPassword (§10.5) carry a password in the clear. On a
kernel-mediated local socket, anything able to read those bytes could as
well read the memory of the process that typed them; what must never
travel is anything a verifier could be recomputed from, and nothing here
does. Both parties SHOULD wipe a buffer that held a password once they
are finished with it.
10.4 Message Framing
Peios / Peios technical reference / PSPU / Local Principal Administration
10.4.1 Header #
Every message begins with PGSS Logon's 12-byte header (PGSS §2.6), unchanged, with this chapter's magic:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | magic | PLPS (50 4c 50 53) |
| 4 | 2 | version | 1 |
| 6 | 2 | msg_type | See §10.A |
| 8 | 4 | total_len | Header plus body, in bytes |
The magic MUST be checked on every message, and MUST end the exchange when wrong. PLPS shares PGSS Logon's codec and some of its structures, so a socket plugged into the wrong daemon would otherwise decode several fields before going wrong.
The high bit of msg_type marks a message sent by the store daemon,
which is the authority for its own store. A party MUST check the type it
received against the one it expected, not only against that bit.
10.4.2 Size limit #
A message MUST NOT exceed 2 MiB (2,097,152 bytes) in total, larger
than PGSS Logon's limit so that Principals can carry a whole store's
listing (§10.5). A party MUST reject a header declaring more without
reading the body.
10.4.3 Encoding #
Integers, strings, byte strings, arrays and length-framed structures are
encoded exactly as PGSS §2.6 specifies, and a body is one length-framed
structure. The two length mechanisms are the ones that section warns
against confusing: a string or byte string is a u32 byte count and its
bytes; a structure or array element is a u32 byte count of the whole
structure, and a decoder skips to that end after the fields it knows.
Booleans are a u8: zero is false, anything else is true.
SIDs are byte strings carrying the binary SID (PCDS §4), at most 68 bytes.
An optional string is a u8 presence flag followed by a string: the
string is meaningful only where the flag is non-zero. It exists where
"leave this alone" and "set this to empty" are both requests a client
makes, and PGSS's empty-means-absent rule could not tell them apart.
A claim is encoded as PSI encodes one (§2.13): a length-framed
structure of name, flags, value_type and values. Its value types
and flags are PCDS §5.9's, and are closed here as they are on PSI
(§2.A).
10.4.4 Extending #
The rules of PGSS §2.6 bind this chapter: fields are appended only, an appended field is optional with a stated default, and a new value in an enumeration is a breaking change requiring a version bump.
A decoder that reaches the end of a structure where an appended field would have been MUST substitute that field's default. This chapter states each default where the field is defined; a client and a store daemon of different ages then degrade rather than fail.
10.5 Principals
Peios / Peios technical reference / PSPU / Local Principal Administration
Every request naming a principal names it by name, a string of at most
256 bytes, matched without regard to case. A store daemon MUST answer
NotFound (§10.9) for a name it holds no principal by.
10.5.1 List #
msg_type = 0x0001. Empty body. Answered with Principals.
10.5.2 Principals #
msg_type = 0x8001. Every principal in the store, as an array of at
most 4096 length-framed entries:
| Field | Encoding | Default if absent |
|---|---|---|
name | string, 256 bytes | |
rid | u32 | |
enabled | boolean | |
groups | u32: how many groups it is listed in | |
unix_id | u32: its effective Unix ID (§10.2) | 0 |
groups counts listed memberships only, not the primary group.
10.5.3 Show #
msg_type = 0x0002. Body: name. Answered with Principal.
10.5.4 Principal #
msg_type = 0x8002. One principal in full:
| Field | Encoding | Default if absent |
|---|---|---|
name | string, 256 bytes | |
rid | u32 | |
enabled | boolean | |
sid | SID | |
groups | array of at most 128 group references | |
unix_id | u32: effective | 0 |
primary_group | one length-framed group reference | an empty reference |
home | string, 4096 bytes | empty |
shell | string, 4096 bytes | empty |
display_name | string, 256 bytes | empty |
claims | array of at most 64 claims (§10.4) | none |
permitted_logon_types | u32, as on Add | 0, not stated |
credential_policy | u8 (§10.8) | not said |
credential_policy is closed, as §10.8 says: a client MUST treat a
value it does not know as a reply it cannot read. A client given no
credential_policy MUST NOT show one; the store daemon did not say.
A group reference is a length-framed structure:
| Field | Encoding | Default if absent |
|---|---|---|
sid | SID | |
name | string, 256 bytes: what this machine calls the group | empty |
unix_id | u32: its effective Unix ID, or zero | 0 |
The store daemon names each group, a well-known one included, and gives
its number where it knows it, so a client never builds a SID or resolves
a name itself. An empty name means the store daemon knows no name for
the SID; zero means it cannot say the number.
10.5.5 Domain #
msg_type = 0x0003. Empty body. Answered with DomainIs.
10.5.6 DomainIs #
msg_type = 0x8003. Body: sid, the store's domain SID (§10.2).
Domain is the smallest request there is, changes nothing, and is
refused to anyone who may not administer the store (§10.3). A client MAY
send it to find out whether its caller may administer the store, before
offering to change anything.
10.5.7 Add #
msg_type = 0x0004. Creates a principal. Answered with Created
carrying its RID.
| Field | Encoding | Default if absent |
|---|---|---|
name | string, 256 bytes | |
credential_kind | u8: 0 none, 1 a password | |
secret | byte string, 4096 bytes | |
enabled | boolean | |
groups | array of at most 128 length-framed strings: groups as written (§10.2) | |
permitted_logon_types | u32 | 0, not stated |
primary_group | optional string (§10.4), 256 bytes: a group as written | absent |
home | optional string, 4096 bytes | absent |
shell | optional string, 4096 bytes | absent |
display_name | optional string, 256 bytes | absent |
credential_kind says plainly whether the principal has a password,
rather than an empty secret meaning none, so that a client cannot make
a principal that needs no credential by accident. secret is present
either way, and empty for 0. A store daemon MUST refuse an empty
secret with kind 1 as Invalid, and MUST refuse a kind it does not
know.
A principal made with kind 0 has the credential policy NoCredential
(§10.8): it signs in with nothing collected. One made with kind 1 has
the policy Password.
permitted_logon_types is the set of PGSS §2.16's, as PSI carries it
(§2.13). Zero is not stated, which the authority reads as its default:
every kind of sign-in a person could use, and never a service sign-in.
The principal gets the store's next RID. Where primary_group, home,
shell or display_name is absent, it gets what the store daemon
chooses (on Peios, Authenticated Users, /home/<name>, /bin/sh and
no display name). A present field is checked as SetProfile and
SetPrimaryGroup check it, and a store daemon MUST refuse the whole
request if it refuses any field, creating nothing: a principal is made
whole or not at all.
A store daemon that predates the last four fields ignores them, and
makes the principal with its own choices. A client cannot tell that
from the reply; one that must know reads the principal back with Show.
A store daemon MUST refuse a name it already holds, a principal's or a
local group's, as Exists, and a name it will not accept as Invalid
with the reason. Which names it accepts is its own. On Peios a name is 1
to 256 bytes of printable ASCII, may not contain @ \ / : ,, and may
not be a well-known group's name.
10.5.8 Remove #
msg_type = 0x0005. Body: name. Deletes the principal. Answered with
Done.
Its RID is not reissued, so its SID keeps appearing in the descriptors of whatever it owned, naming nobody. A client SHOULD say so before removing one, and SHOULD offer disabling instead.
10.5.9 SetEnabled #
msg_type = 0x0006. Body: name, then enabled, a boolean. Answered
with Done. A disabled principal keeps everything, and cannot sign in.
10.5.10 SetPassword #
msg_type = 0x0007. Body: name, then secret, a byte string of at
most 4096 bytes, never empty. Sets the principal's password. Answered
with Done.
It does not change the principal's credential policy (§10.8). A
principal whose policy accepts no password is not given one by this
request: a client that means the password to be used MUST also send
CredentialPolicy.
10.5.11 SetProfile #
msg_type = 0x000d. Changes part of a principal's profile, leaving the
rest alone. Answered with Done.
| Field | Encoding |
|---|---|
name | string, 256 bytes |
home | optional string (§10.4), 4096 bytes |
shell | optional string, 4096 bytes |
display_name | optional string, 256 bytes |
An absent field is left as it is. A present empty display_name clears
it. A store daemon MAY refuse a value it will not accept as Invalid;
on Peios home and shell must be absolute paths.
10.5.12 Rename #
msg_type = 0x0015. Changes what a principal is called. Answered with
Done.
| Field | Encoding |
|---|---|
name | string, 256 bytes: the principal |
new_name | string, 256 bytes: what it is to be called |
Its SID, RID and Unix ID stay, so everything that names it by SID, every
descriptor and every token already minted, still does. Its home directory
stays too: a store daemon MUST NOT change home on a rename. A client
that means the home directory to follow sends SetProfile as well.
A store daemon MUST refuse a new_name another principal or a local
group holds as Exists, and applies the rules for a name as Add does.
A new_name differing from the principal's name only in case is a
rename; one equal to it is Done and changes nothing.
10.5.13 SetLogonTypes #
msg_type = 0x0016. Sets which kinds of sign-in a principal may be
used for. Answered with Done.
| Field | Encoding |
|---|---|
name | string, 256 bytes |
permitted_logon_types | u32, as on Add |
Zero returns the principal to not stated, the authority's default.
There is no value meaning "no sign-in at all"; a client disables the
principal for that. A store daemon MUST keep bits for logon types it
does not know as they are given, as it does on Add: the authority
checks the types it knows.
10.5.14 Last administrator #
A store daemon MUST refuse, as Invalid, a request that would leave the
store with no enabled principal in BUILTIN\Administrators who can sign
in: removing or disabling the last one, removing the last one from that
group (§10.6), setting the last one's permitted_logon_types to a
set that permits none of interactive, remote interactive or network
sign-in, or leaving the last one nothing to sign in with (§10.8): a
CredentialPolicy it would not have something to sign in with under,
or a KeyRemove of the last key it signs in with. A machine left so
has nobody to administer it.
10.6 Groups and Membership
Peios / Peios technical reference / PSPU / Local Principal Administration
10.6.1 GroupList #
msg_type = 0x000a. Empty body. Answered with Groups.
10.6.2 Groups #
msg_type = 0x8007. Every local group in the store, as an array of
at most 4096 length-framed entries:
| Field | Encoding | Default if absent |
|---|---|---|
name | string, 256 bytes | |
rid | u32 | |
unix_id | u32: its effective Unix ID (§10.2) | |
sid | SID | |
members | u32: how many principals list it | |
description | string, 1024 bytes | empty |
The well-known groups are not in it: the store holds none of them as
objects (§10.2). members counts listed memberships, not principals
whose primary group it is; the identity socket's MEMBERS counts both
(PGSS §2.16).
A group's description says what it is for, on one line, for a person
to read; empty is none. Anyone may read it, on the identity socket's
DESCRIPTION (PGSS §2.16), where a store daemon that is a principal
source answers it.
10.6.3 GroupCreate #
msg_type = 0x000b. Creates a local group, with the store's next RID.
Answered with Created carrying it.
| Field | Encoding | Default if absent |
|---|---|---|
name | string, 256 bytes | |
description | string, 1024 bytes | empty |
A store daemon MUST refuse a name it already holds, a principal's or a
group's, as Exists, and applies the same rules to a group's name as to
a principal's (§10.5). It MAY refuse a description it will not keep as
Invalid; on Peios one may hold no control characters, and is kept with
the spaces at its ends removed.
A store daemon that predates description ignores it, and makes the
group with none, as Add describes (§10.5).
10.6.4 GroupRename #
msg_type = 0x0017. Body: name, a local group's, then new_name,
as Rename (§10.5). Changes what the group is called. Answered with
Done.
Its SID, RID and Unix ID stay, and so does every membership of it, since
a membership names the group by SID. A store daemon MUST answer
NotFound where it holds no local group by name, and refuses
new_name as Rename does.
10.6.5 GroupDescribe #
msg_type = 0x0018. Sets a local group's description. Answered with
Done.
| Field | Encoding |
|---|---|
name | string, 256 bytes: a local group's |
description | string, 1024 bytes; empty clears it |
A store daemon MUST answer NotFound where it holds no local group by
name. The well-known groups' descriptions are the authority's, and
cannot be set here.
10.6.6 GroupDelete #
msg_type = 0x000c. Body: name, a local group's. Deletes it.
Answered with Done.
A store daemon MUST refuse, as Invalid, deleting a group any principal
lists or has as its primary group: a principal would be left in a group
that no longer exists. A client removes the members first.
A store daemon MUST answer NotFound where it holds no local group by
name, a well-known group's name included.
10.6.7 GroupAdd and GroupRemove #
GroupAdd, msg_type = 0x0008, puts a principal in a group;
GroupRemove, msg_type = 0x0009, takes it out. Both are answered
with Done.
| Field | Encoding |
|---|---|
name | string, 256 bytes: the principal |
group | string, 256 bytes: the group as written (§10.2) |
name MUST name a principal: groups do not nest, and a store daemon MUST
answer NotFound for a group's name there.
group MAY name a local group, a BUILTIN group, or any group by its
SID. A membership of a BUILTIN group is recorded here on the machine's
behalf, and asserted at sign-in under PSI's membership-scope exception
(§2.19); it is how a principal becomes an administrator. A membership of
a group in another domain is recorded as given.
Both are idempotent: adding a membership already held, or removing one
not held, is Done and changes nothing. A principal MAY be listed in at
most 128 groups; a store daemon MUST refuse a 129th as Invalid.
Removing a principal from BUILTIN\Administrators is refused if it is
the last enabled one there (§10.5).
A store daemon MUST answer NotFound for a group that names nothing it
can resolve.
10.6.8 SetPrimaryGroup #
msg_type = 0x000e. Body: as GroupAdd. Makes group the principal's
primary group: the one it projects to as its POSIX group ID. Answered
with Done.
A primary group is a membership (§2.13): the principal is in it whether or not it is also listed.
10.7 Claims
Peios / Peios technical reference / PSPU / Local Principal Administration
A principal's claims are named, typed attributes that the authority
puts on the tokens it mints for them, and that conditional ACEs are
evaluated against (PCDS §5.9). Principal carries them (§10.5).
10.7.1 SetClaim #
msg_type = 0x000f. Sets a claim, replacing one of the same name.
Answered with Done.
| Field | Encoding |
|---|---|
name | string, 256 bytes: the principal |
claim | one claim (§10.4) |
Claim names match without regard to case. A store daemon MUST refuse, as
Invalid, a claim PSI could not carry to the authority (§2.13): a name
empty or longer than 255 bytes or containing a NUL, a flag or value type
it does not recognise, more than 64 values, or a string or octet value
longer than 1024 bytes. It MUST refuse a 65th claim on one principal.
10.7.2 RemoveClaim #
msg_type = 0x0010. Removes a claim. Answered with Done.
| Field | Encoding |
|---|---|
name | string, 256 bytes: the principal |
claim_name | string, 255 bytes |
Removing a claim the principal does not hold is Done and changes
nothing.
10.8 Keys and Credential Policy
Peios / Peios technical reference / PSPU / Local Principal Administration
A principal's credential policy says which credentials it may sign in with. Its SSH public keys are what it may sign in with by key (PGSS §2.D). The two are separate on purpose: adding a key does not let anyone sign in with it until the policy says keys are accepted, so enrolling a key and allowing key sign-in are two decisions, and neither happens as a side effect of the other.
10.8.1 Credential policies #
| Value | Policy | Signs in with |
|---|---|---|
0 | Denied | nothing: every sign-in is refused |
1 | Password | its password |
2 | SshPublicKey | one of its SSH keys |
3 | PasswordOrKey | either |
4 | NoCredential | nothing collected (§10.5, Add) |
The enumeration is closed (§10.4). A store daemon MUST refuse a value it does not know.
10.8.2 KeyList #
msg_type = 0x0011. Body: name. Answered with Keys.
10.8.3 Keys #
msg_type = 0x8008. The principal's credential policy and keys:
| Field | Encoding |
|---|---|
policy | u8 |
keys | array of at most 32 length-framed keys |
A key:
| Field | Encoding |
|---|---|
id | byte string, 16 bytes: the store daemon's, never all zero |
fingerprint | string, 128 bytes: as ssh-keygen -l writes one |
label | string, 128 bytes |
created | u64: seconds since the Unix epoch |
Detail (§10.5) answers the credential policy too, as a trailing
optional field; KeyList answers it from every store daemon.
10.8.4 KeyAdd #
msg_type = 0x0012. Enrols an SSH public key. Answered with Done.
| Field | Encoding |
|---|---|
name | string, 256 bytes |
public_key | string, 16384 bytes: one line of an OpenSSH authorized_keys file |
label | string, 128 bytes, no control characters |
A store daemon MUST refuse, as Invalid, a key it cannot read or does
not support, a key the principal already has, a 33rd key, and a label
too long or carrying a control character. It does not change the policy.
10.8.5 KeyRemove #
msg_type = 0x0013. Body: name, then id, a byte string of 16
bytes. Removes the key. Answered with Done, or NotFound for an id
the principal has no key by. It is refused as the last-administrator
rule says (§10.5).
10.8.6 CredentialPolicy #
msg_type = 0x0014. Body: name, then policy, a u8. Sets the
principal's credential policy. Answered with Done. It is refused as
the last-administrator rule says (§10.5).
A principal has something to sign in with when its policy is
NoCredential; Password and it has a password; SshPublicKey and it
has a key; or PasswordOrKey and it has either. Denied never does.
10.9 Replies and Refusal
Peios / Peios technical reference / PSPU / Local Principal Administration
Every request has exactly one successful reply, listed in §10.A, and any
request may instead be answered with Failed.
10.9.1 Created #
msg_type = 0x8004. Body: rid, a u32. Answers Add and
GroupCreate: the two requests that allocate a RID, and so have
something to report.
10.9.2 Done #
msg_type = 0x8005. Empty body. Answers every other request that
changes the store.
10.9.3 Failed #
msg_type = 0x8006. The request was refused.
| Field | Encoding |
|---|---|
failure | u32: why, from the table below |
reason | string, 512 bytes: why, in words |
| Value | Failure | Means |
|---|---|---|
1 | Denied | The caller may not administer the store (§10.3). |
2 | NotFound | Nothing in the store by the name, or the key, given. |
3 | Exists | The name asked for is already held. |
4 | Invalid | Well-formed, and asking for something the store will not do. |
5 | Internal | The store could not be read or written. |
The enumeration is closed (§10.4). failure is for a client that
branches on it; reason is for a person, and a client SHOULD show it as
it is rather than interpret it.
A store daemon MUST answer a request it cannot decode, or whose
msg_type this chapter does not define, with Invalid.
There is no failure for "you may not see that". Administering is not authenticating, and a caller already allowed to administer the store has nothing to learn from a refusal that it could not learn by listing.
10.9.4 A change is applied whole, or not at all #
A store daemon MUST NOT acknowledge a change, with Created or Done,
before it has been made durable. A change that cannot be saved MUST be
undone and answered Internal; a refused request MUST leave the store as
it was.
Every change acknowledged is also announced to the authority, as PSI's
Changed (§2.17), so that what it answers next reflects it. A client
here is told nothing when somebody else changes the store: one that shows
the store SHOULD read it again after its own changes and from time to
time.
10.10 Conformance
Peios / Peios technical reference / PSPU / Local Principal Administration
10.10.1 A conforming store daemon #
The channel. Decides from the caller's token, before reading the
request, whether the caller may administer the store, admitting SYSTEM
and a token carrying BUILTIN\Administrators enabled, and treating a
deny-only group as no membership; refuses anyone else with Denied
(§10.3). Serves one request a connection, answers it exactly once, and
closes (§10.3).
Framing. Checks the magic and the version of every message, refuses one over 2 MiB unread, and substitutes the stated default for an appended field a peer did not send (§10.4).
Requests. Answers each with its own reply (§10.A) or Failed, and a
request it cannot decode or does not define with Invalid (§10.9).
Resolves a group as written itself (§10.2). Answers NotFound for a
principal, group or key it does not hold, Exists for a name already
held, and Invalid, with the reason, for anything it will not do
(§10.5 to §10.9).
Its rules. Refuses to leave the store with no enabled administrator
who can sign in (§10.5); to delete a group anyone is in or has as their
primary group (§10.6); a claim PSI could not carry (§10.7); and a key it
cannot read, or holds already (§10.8). Gives a principal made without a
password the policy NoCredential, and one made with one Password
(§10.5). Makes a principal whole or not at all, refusing an Add if it
refuses any field of it (§10.5). Keeps a renamed principal's SID, RID,
Unix ID and home directory (§10.5), and a renamed group's SID, RID, Unix
ID and memberships (§10.6). Holds one name for one principal or
local group, matched without regard to case (§10.5, §10.6). Never
reissues a RID (§10.2).
Changes. Acknowledges a change only once it is durable, undoes one it cannot save, leaves the store untouched on a refusal, and announces every change to the authority (§10.9).
10.10.2 A conforming administrator client #
The channel. Opens a connection for each request and holds none
open (§10.3). Treats a connection refused for permission as Denied,
and a connection closed without a reply as an outcome unknown, reading
the store again before saying the change failed (§10.3).
Requests. Passes a group as the person wrote it, resolving nothing
itself (§10.2). Sends credential_kind 0 only where a principal is
meant to need no credential (§10.5). Sends CredentialPolicy alongside
SetPassword where the password is meant to be used (§10.5). Reads a
principal back with Show where it must know that an Add's profile
was applied (§10.5). Shows no credential policy where the store daemon
gave none (§10.5).
Refusals. Shows a refusal's reason as it is (§10.9).
Reading. Reads principals for display on the identity socket where it need not administer them (§10.1), and reads the store again after its own changes, since nothing tells it of anyone else's (§10.9).
10.10.3 The self socket #
The obligations of a store daemon serving the self socket, and of its clients, are collected in §10.11.
10.11 The Self Socket
Peios / Peios technical reference / PSPU / Local Principal Administration
The admin socket (§10.3) is for administrators, and every request on it names the principal it is about. A principal also needs to see their own account — what they are called, what they sign in with, which SSH keys they have — and to set what they are called, without being an administrator. The self socket serves that: any authenticated principal may connect to it, and no request on it names anybody.
Changing a credential is not here. A display name is not a credential, so setting it asks for no proof; a password or an SSH key is one, so changing either goes through PGSS Logon, where the principal's current password is asked for (PGSS §2.20 and PGSS §2.23).
10.11.1 The socket #
The store daemon listens on a second Unix stream socket. On Peios it is
/run/lpsd/self.sock.
Its descriptor MUST admit every authenticated principal to connect. On
Peios it is D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x100082;;;AU): SYSTEM and
Administrators with full access, and Authenticated Users with exactly
what a connect needs (FILE_WRITE_DATA, READ_ATTRIBUTES and
SYNCHRONIZE), the mask Peios grants them on /run/logon.sock.
The store daemon sets it when it creates the socket and reads it back;
the socket's directory admits nobody the socket is for, so a socket that
inherited it would be closed to everyone it exists to serve.
10.11.2 The subject is the peer #
The subject of every request is the user of the connected peer's token, read by the store daemon from the connection when it accepts it. No request carries a name, a SID or any other way to point at a principal, and the store daemon MUST NOT answer about, or change, any principal but that one.
A peer whose user the store does not hold — SYSTEM, a service
identity, a principal another source holds — is refused with NotFound
and a reason saying so. It is never shown, or allowed to change,
somebody else's account.
10.11.3 Framing #
Messages are PLPS's (§10.4): the same header, magic, encoding and
Failed and Done replies (§10.9). The requests are in a range of
their own, 0x0040 upward, with their reply at 0x8040. Each socket
MUST refuse the other's requests with Invalid: the self socket is not
a second way to administer the store, and the admin socket does not
answer about its caller.
A request on the self socket MUST NOT exceed 4096 bytes. The store
daemon MUST refuse, from its header and without reading the body, one
that declares more, answering Invalid. Replies are bounded by PLPS's
ceiling as on the admin socket.
10.11.4 One request a connection, never served blocking #
Each connection carries one exchange, as on the admin socket (§10.3).
The admin socket admits only administrators, so a store daemon MAY
serve it a connection at a time. The self socket admits everyone, and a
store daemon that also answers PSI (§2) MUST NOT let a connection on it
delay anything else: a principal who connected and sent nothing would
otherwise stall every sign-in behind them. A store daemon MUST serve the
self socket without blocking on any one connection, MUST bound how long
a connection may remain open from accept to the last byte of its reply,
and MUST bound the connections open at once and the connections any one
user, by the token's user SID, holds. A caller over a bound is refused,
with Failed where that will not block, and the connection closed.
On Peios lpsd holds each connection non-blocking in a table its poll loop drives, and drops one at 5 seconds after accept. It holds at most 32 at once and 4 from any one user.
10.11.5 ShowSelf #
msg_type = 0x0040. Empty body. Answered with Self.
10.11.6 Self #
msg_type = 0x8040. The caller's own account.
| Field | Encoding |
|---|---|
name | string, 256 bytes |
sid | byte string, 68 bytes |
display_name | string, 256 bytes; empty when unset |
enabled | boolean |
policy | u8: the credential policy (§10.8) |
has_password | boolean: whether the account has a password at all |
keys | array of at most 32 length-framed keys |
A key:
| Field | Encoding |
|---|---|
id | byte string, 16 bytes: as Keys gives it (§10.8) |
fingerprint | string, 128 bytes: as ssh-keygen -l writes one |
algorithm | string, 64 bytes: the key's type, such as ssh-ed25519 |
label | string, 128 bytes |
created | u64: seconds since the Unix epoch |
has_password is separate from policy because a policy can name a
password the account has not got, and a principal adding or removing a
key must prove a password (PGSS §2.23). A client SHOULD offer key
changes only where the account has one.
This is what Show and KeyList give an administrator about the same
principal, less what is the administrator's to manage. Nothing in it is
a verifier, a key's private half or anything a credential could be
recomputed from.
10.11.7 SetDisplayName #
msg_type = 0x0041. Answered with Done.
| Field | Encoding |
|---|---|
display_name | string, 256 bytes; empty clears it |
The store daemon MUST validate it exactly as it validates SetProfile's
display_name (§10.5), refusing what that refuses with Invalid. It
MUST refuse with Denied a principal that is disabled: a session that
outlived its account's disabling is not a way to change the account.
The change is applied as any other (§10.9): made durable before Done,
undone and answered Internal if it cannot be saved, and announced to
the authority as PSI's Changed (§2.17) so that its identity channel
answers with the new name at once. A request that changes nothing — the
name is already that — MAY be answered Done without writing.
10.11.8 Conformance #
A store daemon serving the self socket admits every authenticated
principal to connect to it; answers every request about the peer's
token's user and nobody else, and refuses a peer it does not hold with
NotFound; refuses the admin socket's requests with Invalid, and a
request over 4096 bytes from its header; never blocks on one connection,
and bounds each connection's time, the connections open, and each user's
connections; validates SetDisplayName as SetProfile, refuses it for
a disabled principal, and makes, undoes and announces it as §10.9
requires.
A client of the self socket names nobody, since there is no field
to; opens a connection for each request; treats a connect refused for
permission as Denied; and changes credentials through PGSS Logon, not
here.
Appendix 10.A Message Reference
Peios / Peios technical reference / PSPU / Local Principal Administration
10.A.1 Messages #
msg_type | Message | Direction | Answered with | Defined in |
|---|---|---|---|---|
0x0001 | List | client → store daemon | Principals | §10.5 |
0x0002 | Show | client → store daemon | Principal | §10.5 |
0x0003 | Domain | client → store daemon | DomainIs | §10.5 |
0x0004 | Add | client → store daemon | Created | §10.5 |
0x0005 | Remove | client → store daemon | Done | §10.5 |
0x0006 | SetEnabled | client → store daemon | Done | §10.5 |
0x0007 | SetPassword | client → store daemon | Done | §10.5 |
0x0008 | GroupAdd | client → store daemon | Done | §10.6 |
0x0009 | GroupRemove | client → store daemon | Done | §10.6 |
0x000a | GroupList | client → store daemon | Groups | §10.6 |
0x000b | GroupCreate | client → store daemon | Created | §10.6 |
0x000c | GroupDelete | client → store daemon | Done | §10.6 |
0x000d | SetProfile | client → store daemon | Done | §10.5 |
0x000e | SetPrimaryGroup | client → store daemon | Done | §10.6 |
0x000f | SetClaim | client → store daemon | Done | §10.7 |
0x0010 | RemoveClaim | client → store daemon | Done | §10.7 |
0x0011 | KeyList | client → store daemon | Keys | §10.8 |
0x0012 | KeyAdd | client → store daemon | Done | §10.8 |
0x0013 | KeyRemove | client → store daemon | Done | §10.8 |
0x0014 | CredentialPolicy | client → store daemon | Done | §10.8 |
0x0015 | Rename | client → store daemon | Done | §10.5 |
0x0016 | SetLogonTypes | client → store daemon | Done | §10.5 |
0x0017 | GroupRename | client → store daemon | Done | §10.6 |
0x0018 | GroupDescribe | client → store daemon | Done | §10.6 |
0x8001 | Principals | store daemon → client | §10.5 | |
0x8002 | Principal | store daemon → client | §10.5 | |
0x8003 | DomainIs | store daemon → client | §10.5 | |
0x8004 | Created | store daemon → client | §10.9 | |
0x8005 | Done | store daemon → client | §10.9 | |
0x8006 | Failed | store daemon → client | §10.9 | |
0x8007 | Groups | store daemon → client | §10.6 | |
0x8008 | Keys | store daemon → client | §10.8 |
Any request may be answered with Failed instead.
10.A.1.1 On the self socket #
msg_type | Message | Direction | Answered with | Defined in |
|---|---|---|---|---|
0x0040 | ShowSelf | client → store daemon | Self | §10.11 |
0x0041 | SetDisplayName | client → store daemon | Done | §10.11 |
0x8040 | Self | store daemon → client | §10.11 |
Done and Failed (§10.9) answer here too. Each socket refuses the
other's requests with Invalid.
10.A.2 Protocol constants #
| Constant | Value | Defined in |
|---|---|---|
| Socket path | /run/lpsd/admin.sock on Peios | §10.3 |
| Self socket path | /run/lpsd/self.sock on Peios | §10.11 |
| Largest self-socket request | 4096 bytes | §10.11 |
| Magic | PLPS (50 4c 50 53) | §10.4 |
| Version | 1 | §10.4 |
| Header size | 12 bytes | §10.4 |
| Maximum message size | 2,097,152 bytes | §10.4 |
| Requests a connection | 1 | §10.3 |
10.A.3 Field limits #
| Field | Limit |
|---|---|
| A principal's or group's name | 256 bytes |
| A password | 4096 bytes, never empty |
home, shell | 4096 bytes |
display_name | 256 bytes |
A group's description | 1024 bytes |
| A principal's listed groups | 128 |
Principals in Principals | 4096 |
Groups in Groups | 4096 |
| A SID | 68 bytes |
| Claims on a principal | 64 |
| A claim's name | 255 bytes |
| A claim's values | 64 |
| A string or octet claim value | 1024 bytes |
| SSH keys on a principal | 32 |
| An SSH public key | 16384 bytes |
| A key's label | 128 bytes |
| A key's fingerprint | 128 bytes |
| A key's algorithm | 64 bytes |
A refusal's reason | 512 bytes |
10.A.4 Enumerations #
| Enumeration | Defined in |
|---|---|
| Failure | §10.9 |
| Credential policy | §10.8 |
| Logon types | PGSS §2.16, carried as PSI carries them (§2.13) |
| Claim value types and flags | PCDS §5.9, closed as on PSI (§2.A) |