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

peipkg

Technical Reference Manual — the package manager: repositories and trust, dependency resolution, installation, transactions and rollback, roles and claims, and package production.

Single-page view · as markdown

1.1 Overview

Peios / Advanced Peios / peipkg / Introduction

peipkg is the package manager of Peios: the program that fetches software from a repository, verifies it, and installs it onto a system. It is the consumer side of the package format and repository protocol (PSPU §5), and it is one of several programs built around that format.

1.1.1 What it does #

peipkg maintains a picture of what is installed, resolves what a requested change implies, and applies that change as a single transaction that either lands completely or does not land at all. Around that core sit the parts that make the core trustworthy: a per-repository trust state, a verification pipeline that runs to completion before any byte reaches its destination, and a journal that survives a power cut mid-write.

1.1.2 What is unusual about it #

It holds no identity. peipkg is not a daemon and has no service principal. It runs as whoever invoked it, and every file it creates or replaces is checked by the kernel against that caller's token. There is no standing privileged process to compromise, and the blast radius of a malicious package is exactly the authority of the person who installed it. The consequence runs both ways: peipkg cannot let a low-authority operator install something they could not have written by hand, and it does not need to be trusted to keep its own hands clean.

Packages carry no permissions. Every entry in a package is mode 0777, owned by uid 0, with no extended attributes. That is honest signalling rather than laxity: a mode bit in a package would imply a contract the kernel does not consult. What access control an installed file ends up with is decided at install time, from the parent directory's inheritable descriptor or from an explicit override the package declares and the operator approves.

There are no install scripts. A package cannot ship code that runs at install time. It can declare that one of three standard maintenance operations is required, from a closed enumerated set, and peipkg invokes that operation itself, from a fixed absolute path, with a cleared environment. Everything a package might otherwise want a script for — registering a service, seeding registry state — belongs to the higher-level artifacts that compose packages.

Installation targets a named root, not a path. The default root is the system root, but a system may define others — an initramfs image is the motivating case — and a package's dependency closure flows into the root the package occupies unless a dependency names a different one. A package never names a filesystem location; it names a root, and where that root lives is the installing system's business.

Several packages may contend for one filesystem name. A role is a virtual name that more than one installed package can provide, with at most one holding it. The holder's file answers the contended path through a symlink peipkg owns. Two registry daemons can be installed at once; only one is /usr/bin/registryd.

1.1.3 The shape of an operation #

Every install, upgrade, and uninstall follows the same arc. peipkg acquires an exclusive lock, resolves the request against the installed set and the configured repositories' indexes, presents the resulting plan for confirmation, fetches and fully verifies every package the plan names, stages each file beside its destination, journals its intent, renames everything into place, commits the database, and only then runs any side effects.

The database commit is the single durability boundary. Before it, a crash rolls the whole transaction back from the journal's record of where each displaced file was moved to. After it, the transaction happened and there is only cleanup left. There is no state in which a transaction is half committed.

1.2 What This Manual Covers

Peios / Advanced Peios / peipkg / Introduction

This manual describes peipkg as it is built: the package manager, the producer toolchain that feeds it, the image composer that shares its machinery, and the repository publisher.

1.2.1 Covered here #

  • The programs and where their state lives (chapter 2)
  • Repository configuration, trust, and refresh (chapter 3)
  • Dependency resolution: satisfaction, candidate selection, and failure (chapter 4)
  • Installation: validation, staging, extraction, and registration (chapter 5)
  • Upgrade and removal, and how configuration files survive them (chapter 6)
  • Transactions: the lock, the journal, the commit, and crash recovery (chapter 7)
  • Rollback and recovery from an interrupted or failed operation (chapter 8)
  • Roles and claims (chapter 9)
  • Installation roots and image composition (chapter 10)
  • Side effects (chapter 11)
  • Producing packages with pekit (chapter 12)
  • The security model: privilege, audit, and operator authorisation (chapter 13)
  • What goes wrong and what it looks like (chapter 14)

1.2.2 Covered elsewhere #

The package format and the repository protocol are specified in PSPU §5 and are not restated here. Anything a third party has to reproduce exactly — the container layout, the manifest schema, version comparison, the payload rules, the signature construction, the index schemas, the freshness rules — lives there. This manual describes what peipkg does with those artifacts, and cites the specification rather than paraphrasing it.

Security descriptors and the access-check model belong to the kernel's access-control subsystem and are documented with it. peipkg supplies descriptor bytes at file-creation time and never interprets them.

Roles, role features, core features, and applets are separate subsystems that reference packages. A package is a distribution primitive beneath them.

Service definitions, registry seeds, and reconciller manifests are integration metadata belonging to the artifacts that compose packages, not to packages.

1.3 Terminology

Peios / Advanced Peios / peipkg / Introduction

Terms defined in PSPU §5.2 — package, manifest, files manifest, payload, repository, descriptor, index, virtual name, role, claim, holder, installation root, trust anchor — carry the same meaning here and are not redefined.

Terms specific to the implementation:

  • Transaction — the atomic unit of work. Every install, upgrade, uninstall, grant, and revoke executes within one, even when it contains a single operation.

  • Plan — the resolver's output: an ordered list of operations that, applied to the installed set, satisfies the request. A plan is computed entirely from index data, before anything is fetched.

  • Goal (or target) — one requested operation the operator named: install this, upgrade that, remove the other.

  • Candidate — an available package, drawn from a repository index, that the resolver may select.

  • World — the resolver's working model, keyed by (name, root): every installed package plus every operation in flight.

  • Journal — the record of a transaction's intent, stored as rows in the package database. It carries the backup map: for each displaced file, the sibling path its original was renamed to.

  • Staged file — a file written to a temporary sibling of its destination, within the same directory, before the transaction commits. Nothing appears at a final install path until the apply phase.

  • Backup — a displaced original, renamed aside within its own directory. Backups cost no additional disk space and are produced by a single rename.

  • Authorization — a resolver output demanding an explicit, action-specific act from the operator before the plan may be applied. Distinct from a notice, which is informational and never blocks.

  • Adoption — recording an existing unowned file as owned by an installing package, without rewriting it, when its content already matches what would have been installed.

  • Side effect — one of the three standard maintenance operations a package may declare (PSPU §5.24).

  • Recipe — a pekit.toml file plus its build script: the input to the producer toolchain describing how to turn an upstream source tree into one or more packages.

  • Lock (in composition) — the pinned, resolved closure an image composition records, so that the same inputs produce the same image. Not to be confused with the transaction lock, which is a mutual exclusion primitive.

1.4 Compatibility

Peios / Advanced Peios / peipkg / Introduction

1.4.1 Format and protocol version #

peipkg implements schema_version 1 of every document in PSPU §5: the manifest, the files manifest, the signature envelope, the repository descriptor, and both indexes. It rejects any other value, and rejects any value outside a closed enumeration — a side-effect identifier, a hash or signature algorithm, a key status, an index kind, a signature policy — rather than ignoring it.

Unknown fields are ignored everywhere except the signature envelope, which is parsed strictly.

1.4.2 Architectures #

x86_64 is the primary target and the one the system is built and tested for. aarch64 is recognised and is a secondary target.

Architecture identifiers are validated for format wherever they appear, but membership of the canonical set is not itself checked: a package declaring an architecture peipkg has never heard of parses cleanly and is simply not installable, because it matches neither the system's primary architecture nor noarch. This is the behaviour a future architecture addition needs, and it means an unrecognised architecture produces a resolution failure rather than a parse failure.

The system's primary architecture is recorded in the package database at first use. When no value is recorded, peipkg derives one from the architecture it was itself built for.

1.4.3 Multi-architecture #

Only one architecture's packages may be installed on a system at a time, alongside noarch packages. The architecture triplet convention (PSPU §5.15) applies regardless, so that today's packages stay compatible with a future extension that lifts the restriction.

1.4.4 The producer toolchain #

pekit is the producer. It builds through the same packing library peipkg reads with, so a package that packs has already been decoded by the consumer's own validators. Recipes are versioned by convention rather than by a schema field: a tool tolerates a top-level section it does not own and rejects an unknown key within a section it does.

1.4.5 Interoperability #

A package is a Zstandard-compressed pax tarball and can be inspected with ordinary tools. Extracting one on a non-Peios host yields world-writable files, because every entry is mode 0777 by design (PSPU §5.16); applying sensible host-native permissions afterwards is the extracting tool's job.

A repository is static files over HTTP. Anything that can serve a directory tree can host one, and anything that can fetch a URL and verify an Ed25519 signature can consume one.

2.1 peipkg

Peios / Advanced Peios / peipkg / The Tools

peipkg is the consumer: the program an operator runs to change what is installed on a system.

2.1.1 Verbs #

VerbEffect
installAdd packages, resolving and installing their dependency closure
upgradeMove installed packages to newer versions; with no name, every installed package
downgradeMove a package to an older version, requiring explicit authorisation
uninstallRemove packages, cascading to or refusing on dependents
undoRevert the effect of a previous transaction
claimInspect, grant, or revoke the holder of a role
repoAdd, list, remove, and refresh repositories
query, list, info, ownsRead the package database
verifyRe-hash installed files against what was recorded at install
recoverResolve an interrupted transaction
cleanGarbage-collect the index cache

install also accepts a local package file rather than a repository name. A local file has no originating repository and therefore no trust set to verify its signature against; it is accepted on the operator's say-so and its format is validated in full, but its authenticity is not established.

2.1.2 Flags that change what a transaction is allowed to do #

FlagEffect
--yesConfirm the routine "apply this plan?" prompt
--cascadeOn uninstall, remove dependents rather than refusing
--allow-staleProceed despite a repository's trust state exceeding its maximum age
--claim, --claim-all, --no-claimChange which roles an install claims (§9.4)
--dangerously-bypass-path-restrictionsPermit an out-of-layout payload from a package that declares itself a special system package

--yes confirms the routine prompt and nothing else. Every elevated action — a downgrade, a foreign replaces, a low-trust provider filling a high-trust role — raises a distinct authorization that --yes does not satisfy and that is confirmed on its own terms (§13.4).

2.1.3 Exit behaviour #

A refused plan, a failed verification, and a rolled-back transaction all exit non-zero and name the condition. A committed transaction whose side effects failed exits zero with a warning: the packages installed, and a stale cache is recoverable by re-invocation (§11.3).

2.2 pekit

Peios / Advanced Peios / peipkg / The Tools

pekit is the producer: the build tool that turns an upstream source tree into one or more package files. It is described in full in chapter 12.

For the purposes of this chapter, three facts matter.

pekit builds through the consumer's own decoder. Packing runs the manifest it just generated back through the consumer's validators, so a package that packs successfully has already satisfied the rules a consumer applies on the way in. A recipe error therefore often surfaces as a manifest error at pack time rather than as a recipe error at validation time.

pekit's own version model is not the package version model. pekit tracks upstream releases — git tags, directory listings — and orders them by its own rules, which exist to answer "is there something newer upstream". Package versions are compared by PSPU §5.6. The two are separate, and where a recipe's template variables expose version components they come from pekit's model.

pekit signs. A package is signed at pack time with a key named by the recipe's signing configuration, and the signature entry is the last thing written into the archive before compression.

2.3 peipkg-repo

Peios / Advanced Peios / peipkg / The Tools

peipkg-repo is the publisher: it maintains the static file tree a repository serves.

2.3.1 Verbs #

VerbEffect
initEstablish a new repository tree, with a descriptor and empty indexes
publishIngest one or more package files, derive both indexes, and sign everything
verifyCheck a published tree for internal consistency

2.3.2 What publishing does #

Publishing verifies each incoming package in full — archive structure, manifest, files manifest, per-file hashes, and its signature against the repository's own keys — before it derives anything from it. Index entries are then extracted directly from each verified manifest, never from operator input.

Both indexes are rewritten on every publication and both are stamped with the same index_version and generated_at. The new version is one greater than the highest either index carried, so the pair advances together and a consumer holding one has a usable freshness floor for the other.

2.3.3 Invariants it defends #

init refuses to run in a non-empty directory. Establishing a fresh repository at index_version 1 over an existing one would look to every consumer like an unrecoverable rollback.

publish refuses to re-publish a name, version, and architecture that already exists. That is the retention guarantee of PSPU §5.35 enforced at the point where it could be broken.

publish refuses a URL template that cannot distinguish one version from another, because such a template makes retention unkeepable: the second version published would overwrite the first.

Deriving the active index from the archive is a projection to the highest version per name. When two architectures of one package tie on version, the publisher stops with an error rather than choosing — a repository that silently picked one would advertise a different package than its operator intended.

2.3.4 What it does not do #

There is no verb for rotating a signing key or marking one revoked. Changing a descriptor's key list means editing repo.json and re-signing it by other means.

Publishing does not check a package's payload against the install-layout rules. A package whose payload lands outside the permitted destinations can be published and served; every consumer refuses it at install time instead.

2.4 peipkg-compose

Peios / Advanced Peios / peipkg / The Tools

peipkg-compose builds a filesystem tree from packages, without installing anything onto the machine doing the building. It is how an image is assembled.

It shares peipkg's resolver, its archive reader, its claim logic, and its package database schema. What it does not share is the runtime: it has no transaction journal to recover, no lock to hold against a running system, and no operator sitting in front of it.

Composition runs in two phases, and they can be run separately.

Resolve performs the full repository trust ceremony, resolves the requested package set against the configured repositories' indexes, and writes a lock: the pinned closure, with each package's URL and hash.

Build reads the lock, fetches each package, checks its bytes against the hash the lock recorded, and assembles the tree — extracting payload, materialising claim links, and seeding a package database so that the resulting image knows what it contains.

Chapter 10 describes composition in detail, including what it does not do that an installed system's peipkg would.

2.5 The Package Database

Peios / Advanced Peios / peipkg / The Tools

peipkg's entire persistent state is one database. It is a transactional store — SQLite in write-ahead-logging mode — and that choice is load-bearing rather than incidental.

2.5.1 Why it is a real database #

Three of this manual's guarantees rest on it.

The commit is atomic. A transaction's new installed state and the closing of its journal entry are written in one database transaction, so the transaction is committed or it is not. There is no window in which it is partly committed, which is why recovery never has to finish a half-done commit (§7.8).

Reads see a consistent snapshot. A query beginning at some moment sees committed state as of that moment, regardless of a write committing underneath it. This is what lets a read-only query run without taking the transaction lock at all.

Constraints are enforced by the schema. The rule that two packages cannot own the same non-directory path is a partial unique index, not an application check — so it holds even against a code path that forgot to look.

peipkg refuses to open a database that is not in write-ahead-logging mode, rather than proceeding with weaker guarantees than it documents.

2.5.2 What it holds #

ContentPurpose
Installed packagesName, version, architecture, originating repository, install time, and the stored manifest
Owned filesOne row per path a package owns, with its type and the hash recorded at install
RepositoriesBase URL, trust keys with their statuses, priority, signature policy, and the recorded freshness floor
Role holdersWhich package holds each role
Claim linksWhich links have been materialised, and for which role and slot
TransactionsThe journal: pending and completed transactions, their operations, and the backup map
Machine metadataThe system's primary architecture, and the registered installation roots

The journal is rows in this database rather than a separate file with a separate format. Recording intent and committing are ordinary database writes, and the journal inherits the database's transactional guarantees.

2.5.3 Protection #

The database is stored under a security descriptor granting write access to the tier of principals permitted to install packages on the system. That descriptor is the journal's integrity protection: a principal outside the tier cannot forge a journal entry, and a principal inside it already holds installation authority, so a write from within is not an escalation.

The staging area is stored under the same descriptor.

Note

An earlier design signed each commit record with a key bound to a dedicated package-manager principal. peipkg holds no principal of its own (§13.1), so there is no key to sign with; the descriptor on the database is the protection instead. Narrowing that descriptor so the journal is writable only through the package-manager executable becomes possible once an elevated-executable mechanism exists, and would be a tightening rather than a correction.

2.6 On-Disk State

Peios / Advanced Peios / peipkg / The Tools

peipkg keeps four kinds of state, in three places.

2.6.1 The package database #

One transactional store, holding everything in §2.5. It is the authoritative record of what is installed.

2.6.2 Repository configuration #

One file per repository, in a configuration directory. Each declares the repository's base URL, its trust anchors, its signature policy, its priority, and two tuning values: a minimum acceptable index version, and a maximum trusted age.

The configuration file is the operator's; the recorded trust state — the verified descriptor, its keys and statuses, and the freshness floor — lives in the database. Adding a repository writes both. Removing one deletes both, and leaves installed packages alone.

Configuration is read with no authorisation check of its own. The security descriptor on the configuration directory is what decides who may change a repository's transport policy or its trust anchors.

2.6.3 The index cache #

Fetched indexes and their signatures are cached, content-addressed, with a small pointer file naming the current object for each repository. Caching avoids re-parsing; it does not avoid re-verifying. Every operation that relies on a cached index verifies its signature again, and cross-checks its version and generation timestamp against the freshness floor recorded in the database.

peipkg clean removes cache objects no pointer references.

2.6.4 Staged files and backups #

Neither is a separate directory. A staged file is written as a sibling of its destination, in the destination's own directory, under a name carrying the transaction identifier; a backup is a displaced original renamed to a sibling under a similar name.

Both choices follow from the same requirement: the rename that commits a file is intra-directory, so that it is atomic and cannot fail with EXDEV because the staging area is on a different filesystem. Backups additionally cost no disk space, because nothing is copied.

Where a destination's basename is long enough that adding the marker would exceed the filesystem's name limit, the basename is truncated to fit.

3.1 Configuration

Peios / Advanced Peios / peipkg / Repositories

A repository is configured by a file naming its base URL and the policy peipkg applies to it.

SettingMeaning
Base URLWhere the descriptor and everything it points at are served from
Trust anchorsExpected key fingerprints, supplied out of band
Signature policyrequired or optional (PSPU §5.37)
PriorityA positive integer; lower is higher priority
Minimum index versionThe out-of-band freshness floor applied at first add
Maximum trusted ageHow long a repository's trust state stays usable without a refresh
Insecure transportWhether a non-HTTPS base URL is permitted for this repository

The default signature policy for a new repository is required. The default maximum trusted age is 30 days. The default priority is the same for every repository, including the official one, so the ordering the resolver applies is whatever the operator configured rather than something peipkg assumes.

An explicit maximum trusted age of zero is rejected rather than treated as "use the default", so that a mistyped value cannot silently disable the freshness check.

3.1.1 The local handle #

A repository has a name in the descriptor and a handle in the local configuration. They are conventionally the same. peipkg identifies a repository internally by the local handle, and compares an index's declared repository name against that handle — so a configuration whose handle differs from the descriptor's name produces a repository that adds successfully and is then skipped at every install, with a warning rather than an error.

3.1.2 Transport #

A base URL is HTTPS unless the repository's insecure-transport setting permits otherwise. The setting is per-repository; there is no global form.

file:// base URLs are accepted for local development. They are exempt from the transport check rather than gated by it, so a repository on removable or network-mounted media is added without the operator acknowledging the transport.

Changing the insecure-transport setting after a repository has been added means editing its configuration file. There is no verb for it, and so no prompt and no audit event accompanies the change.

3.2 Adding a Repository

Peios / Advanced Peios / peipkg / Repositories

Adding a repository is the trust ceremony of PSPU §5.37: the moment an operator decides that a particular key speaks for a particular URL.

3.2.1 The ceremony #

peipkg fetches the descriptor and its detached signature, fetches the public key for each anchor fingerprint the operator supplied, checks each fetched key against the fingerprint that named it, and verifies the descriptor's signature against those anchor keys and only those. On success it records the descriptor's full key list, with statuses, as the repository's trust state.

If no key matching an anchor verifies the descriptor, the add is refused. The error names the condition but not the fingerprints involved, which is the diagnostic a transcription error most needs.

peipkg does not display the fetched fingerprint alongside the supplied one, and does not prompt for confirmation before recording trust. A mismatch is caught — an anchor that does not match cannot verify anything — but the operator is not shown the two side by side.

3.2.2 Two forms #

peipkg repo add <name> <url> --anchor <fingerprint> is the interactive form: everything comes from the command line.

peipkg repo add <name> is the configured form: the URL, the anchors, and the policy are already on disk, placed there by an image or a configuration manager, and the command performs the ceremony against them. When the ceremony fails for a repository whose configuration file already existed, that file is deliberately left in place — the operator put it there, and deleting it would discard their configuration over a transient network failure.

3.2.3 Bootstrapping the freshness floor #

The first index a repository serves establishes the floor that §3.4's rollback protection enforces from then on. A repository that publishes a minimum acceptable index version alongside its anchors lets peipkg refuse an add whose first index falls below it; without one the floor is whatever the first fetch returned.

Adding a repository writes the floor unconditionally, including for a repository already configured. Because the configured form of the command needs no arguments and reads as idempotent, re-running it is the route by which a recorded floor is replaced by whatever the current fetch returns.

3.2.4 Fetching keys before verifying #

The descriptor names the URLs its keys are published at, so peipkg reads an unverified document to know where to fetch from. It fetches every key the descriptor declares, not only those matching the supplied anchors.

3.2.5 Official anchors #

The anchors for the official repository come from a file installed by the base system, outside any package. Bootstrap trust is a property of the image rather than of the package format: peipkg relies on the anchors being present when it first runs.

3.3 Priority

Peios / Advanced Peios / peipkg / Repositories

Every configured repository carries a numeric priority. A lower number is a higher priority.

Priority decides two things: which repository's candidate the resolver prefers when several satisfy the same dependency (§4.3), and which of two repositories counts as the more trusted when a cross-repository guard fires (§3.7).

3.3.1 What priority is not #

peipkg has no notion of an "official" repository as a distinct kind. Wherever the format's rules speak of a non-official repository acting against an official one, peipkg substitutes a comparison of numeric priorities. The two coincide exactly when the official repository has been given the lowest number — which is the recommended configuration but not something peipkg enforces, since every repository including the official one is created at the same default priority.

3.3.2 Local files #

A package supplied as a local file rather than fetched from a repository carries an empty repository name and priority zero. Zero is numerically the highest priority available, so a local file outranks every configured repository in candidate selection, and the same-repository preference of §4.3 is permanently inert for it.

3.4 Refresh

Peios / Advanced Peios / peipkg / Repositories

A refresh brings a repository's recorded trust state and cached index up to date.

3.4.1 The sequence #

peipkg fetches the current descriptor and its signature and verifies the signature against any key that was active or transitioning in the previously trusted descriptor — not against the new descriptor's own keys, which would make the update self-certifying. On success it records the new descriptor, replacing the previous key set, then fetches the active index and verifies it against the new keys.

A failed refresh leaves the previous trust state entirely intact and is reported. peipkg does not fall back to unverified state, and does not silently proceed on a stale cache.

3.4.2 The freshness gate #

An index that verifies is not necessarily current, so a refresh applies the rollback and freeze checks of PSPU §5.34 before accepting it.

  • An index whose version is below the recorded floor is rejected, even though it is correctly signed by a still-trusted key.
  • An index whose generation timestamp precedes the recorded one is rejected.
  • An index whose version and timestamp both equal the recorded values is treated as no progress: the fetch succeeded, but the last-successful-refresh timestamp is deliberately not advanced.

That third case is the anti-freeze rule, and it is the one that makes the maximum-trusted-age check below meaningful. An attacker serving the same signed index indefinitely does not get to keep a consumer's clock ticking forward.

The checks apply to the active index. The archive index is verified for signature and identity but is not subjected to the freshness floor.

3.4.3 Maximum trusted age #

peipkg records the time of the last successful refresh per repository. When that exceeds the repository's maximum trusted age, an install, upgrade, or downgrade against that repository first attempts a refresh. If the attempt fails — or succeeds without progress — the operation is refused unless the operator supplies --allow-stale, which is an elevated authorisation of its own and is audited (§13.4).

Uninstall and undo are deliberately not gated. Removing something, and reverting a change, are exactly the operations an operator needs while offline or while a repository is compromised.

A configured age above 180 days produces a warning on every operation, so that a configuration effectively disabling the check stays visible.

3.4.4 What is not checked #

peipkg gates on how long since it last refreshed. It does not additionally gate on how old the index itself says it is. A repository that increments its index version on every publication while stamping an ancient generation timestamp satisfies the refresh check indefinitely.

3.5 The Index Cache

Peios / Advanced Peios / peipkg / Repositories

Indexes change when a repository publishes; peipkg reads them on every operation. The gap between those two rates is what the cache exists for.

3.5.1 Structure #

A fetched index and its signature are stored as content-addressed objects, with a small pointer file naming the current object for each repository. An older sidecar layout is still read, so a cache written by an earlier version stays usable.

peipkg clean removes objects no pointer references.

3.5.2 Re-verification #

Caching avoids re-parsing JSON. It does not avoid re-verifying signatures. Every operation that relies on a cached index verifies its detached signature again against the repository's current trust state.

peipkg additionally cross-checks a cached index against the freshness state recorded in the database, and rejects one whose index version or generation timestamp disagrees with what was recorded.

Note

The two checks close different holes. Re-verifying the signature stops substituted metadata being trusted between the cache write and the next read. The cross-check against recorded state stops an older validly signed index being dropped directly into the cache, which would otherwise bypass the refresh path where the freshness floor is enforced.

3.5.3 When the cache fails #

A cached index that fails to load or fails to verify produces a warning, and resolution proceeds without that repository.

For a repository the system depends on, that means a package the operator expected to come from it is instead resolved from wherever else it is available, at a lower priority.

3.5.4 Protection #

The cache is written with ordinary file permissions and carries no security descriptor of its own. Its integrity rests on the re-verification above rather than on who can write to it.

3.6 Transport

Peios / Advanced Peios / peipkg / Repositories

Everything a repository serves is a static file fetched over HTTP.

3.6.1 Size caps #

Every fetch is capped, so that a hostile or broken server cannot exhaust memory before anything has been verified.

ArtifactCap
Repository descriptor4 MiB
Detached signature4 KiB
Public key file64 KiB
Index64 MiB
Package filethe index-declared compressed size, plus an allowance

The package allowance is a flat 16 MiB above the declared compressed size, applied uniformly regardless of how large the package is.

3.6.2 URL resolution #

A URL in a descriptor or an index may be absolute, rooted, or document-relative, and each resolves as PSPU §5.36 describes: an absolute URL as-is, a leading-slash URL against the repository base, and anything else against the document that carried it.

3.6.3 Schemes #

HTTPS is required unless the repository's insecure-transport setting is enabled, and enabling it produces no warning of its own on subsequent operations.

file:// is supported for development and is exempt from the transport check entirely rather than gated by the insecure-transport setting.

3.6.4 Failure #

A fetch failure is reported and fails the operation. peipkg does not substitute cached data for a failed fetch without the operator saying so.

3.7 Cross-Repository Guards

Peios / Advanced Peios / peipkg / Repositories

Two relations let one repository act on another's packages, and both are gated.

3.7.1 Foreign replaces #

A replaces declared by a lower-priority repository, targeting a package originally installed from a higher-priority one, raises an authorization. The operator confirms it specifically; a general --yes does not satisfy it, and the authorising act is audited.

Note

Without the gate, a custom repository silently replacing an official-repository package is a routine upgrade away. The confirmation is what stops an escalation happening as a side effect of an operation the operator thought was maintenance.

3.7.2 Foreign conflicts #

A conflicts declared by a lower-priority repository, which would cause a higher-priority package to be removed as a cascade, is the mirror image: a denial-of-availability rather than an escalation.

peipkg resolves a conflict by rejecting the plan outright rather than by cascading removals, so the situation the guard describes does not arise: the low-trust package simply fails to install, and the high-trust one stays where it is.

3.7.3 A low-trust provider filling a role #

When the candidate that satisfies a dependency does so through provides from a lower-priority repository, while a higher-priority repository holds a name-matching package whose constraint check failed, peipkg raises an authorization and requires explicit confirmation. This is the same shape as the foreign-replaces guard: a less-trusted package taking over a name a more-trusted one was expected to fill.

The check runs when resolving a dependency. It does not run for a package the operator named directly, where it cannot fire in practice because a directly named goal carries no constraint for the higher-priority candidate to fail.

3.7.4 Origin that no longer resolves #

Each of these guards compares the priority of the repository a package came from against the priority of the repository acting on it. A package whose originating repository has since been removed has no configured priority to compare, and peipkg skips the comparison — and with it the guard — rather than treating the unknown origin as maximally trusted.

The consequence is worth stating plainly: for packages left behind by a removed repository, the two gates above do not fire.

4.1 Inputs and Outputs

Peios / Advanced Peios / peipkg / Resolution

Resolution is the step that turns "install this" into "do these things, in this order". It runs entirely on index data, before anything is fetched.

4.1.1 Inputs #

  • The goals: the operations the operator asked for. Install a package, upgrade one, downgrade one, or remove one.
  • The installed set: what is on the system now, from the package database, with each package's version, architecture, originating repository, and that repository's priority.
  • The available set: every package in every configured repository's index, each annotated with the repository it came from and that repository's priority.

The resolver's working model is keyed by the pair (name, root). The same package name installed in two roots is two independent entries, possibly at different versions.

4.1.2 Outputs #

Resolution produces either a plan or a rejection.

A plan is an ordered list of operations. It is partially ordered so that for every install, everything it depends on is already installed or is scheduled earlier; removals are ordered in reverse, so that a dependent is removed before the thing it depended on.

Alongside the plan, resolution emits two other things:

  • Authorizations — elevated actions the plan implies, each of which the operator confirms on its own terms before the plan is applied. A downgrade, a foreign replaces, a low-trust provider filling a role.
  • Notices — informational statements that never block. The substitution notice of §4.2 is one.

A rejection names which condition failed and which packages or constraints were involved.

4.1.3 Determinism #

The resolver is a pure function of its inputs: the same goals, installed set, and available set produce the same plan, every time. That is what makes a dry run trustworthy — the plan shown is the plan that would be applied.

Determinism holds with respect to the inputs as given, including the order candidates appear in. Where the selection rules of §4.3 leave two candidates genuinely tied, the one enumerated first wins, so a re-sorted index can change the outcome.

4.1.4 Index-only #

Resolution never downloads a package. Satisfaction checks and candidate selection use only what the index carries, which is why the index carries a package's relationships at all. Fetching is deferred until after the plan is computed and confirmed.

4.2 Satisfaction

Peios / Advanced Peios / peipkg / Resolution

A dependency is satisfied by a candidate when the conditions of PSPU §5.21 hold: the name matches directly or through provides, any constraint is met by the appropriate version, the architecture qualifier is met, and the candidate is in the dependency's root.

Three consequences of those rules shape how peipkg behaves.

4.2.1 A goal may name a role #

An install goal is satisfied under the same conditions as a dependency, with the goal's name in place of the dependency's. An operator may therefore ask to install sh, cc, or coreutils and receive whatever package provides it.

When a goal is satisfied by a candidate whose name differs from the one the operator typed, peipkg reports the substitution. This is a notice rather than an authorization: the operator is told, but an unattended run is not blocked.

Note

Reporting matters because the operator asked for one name and received a package with another. Reproducibility is not at stake — a consumer that records its resolved closure, as an image composition does, pins the chosen package, so the substitution is decided once rather than re-decided on every build.

A goal already satisfied by an installed provider is not recognised as satisfied: peipkg checks whether the goal's name is present, not whether something provides it. Asking to install a role that an installed package already provides therefore installs a second provider.

4.2.2 Upgrade and remove are name-only #

An install goal may resolve through provides. An upgrade, a downgrade, and a removal do not: they act on a package already installed under a specific name, and resolving them through provides would let an upgrade substitute a different package for the one the operator named.

4.2.3 The architecture qualifier #

The only qualifier value is any, and anything else is rejected. any means the candidate's architecture equals the depending package's effective architecture, or is noarch.

For a noarch depender the effective architecture is the system's primary architecture — a script's dependency on its interpreter resolves against the concrete system being assembled. peipkg applies that rule when checking a plan for consistency. It does not apply it while selecting a candidate for a noarch package's dependency, where the architecture test is skipped entirely.

The visible consequence is a plan that could have been satisfied being rejected instead: a foreign-architecture candidate wins selection, and the consistency check then rejects the whole resolution rather than the one candidate.

4.3 Candidate Selection

Peios / Advanced Peios / peipkg / Resolution

When several available packages satisfy one dependency or goal, peipkg chooses between them by applying the following rules in order. The first rule that distinguishes two candidates decides.

  1. An exact architecture match beats noarch. A candidate whose architecture equals the system's primary architecture is preferred over a noarch candidate of the same name.

  2. The depending package's own repository is preferred, bounded. When resolving a dependency for a package D, a candidate from D's repository is preferred over a cross-repository one — but only when D's repository is at least as trusted as the alternative. When D comes from a lower-priority repository than the cross-repository candidate, this rule does not apply and rule 3 decides.

    Note

    The bound is the point of the rule. Without it, a low-trust package pulls in low-trust transitive dependencies that shadow higher-trust alternatives, simply by virtue of being their depender.

    The rule applies when D is being installed or upgraded in this transaction. When D is already installed and is merely the reason a dependency is being resolved, peipkg does not consult the repository D was installed from, so the rule does not fire — and the same dependency can resolve to a different provider depending on whether D is being touched.

  3. Higher repository priority is preferred — a lower numeric priority (§3.3).

  4. A higher version of the name being resolved is preferred. That version is the candidate's own when it matched by name, and the matching provides entry's version when it matched through provides. A candidate matched through an unversioned provides states no version and is preferred less than any candidate that states one.

    Note

    Rule 4 compares the role, not the package. Two packages filling one role are unrelated software on unrelated version scales: asked which coreutils they offer, one project's 9.9 and another's 0.4 answer with the versions in their provides entries. Comparing those as package versions would decide the role by which project numbers its releases higher.

  5. A higher package revision is preferred — already implied by rule 4, retained for clarity.

  6. Ties break on the candidate's package name, by byte order, and then on its repository name.

4.3.1 When the rules run out #

Rules 1 to 6 do not order two different versions of the same package matched through an unversioned provides: they carry no role version to compare at rule 4, and they agree on name and repository at rule 6. Such a pair is a complete tie, and the candidate enumerated first wins.

The outcome is therefore an artefact of the order the index was read in rather than a consequence of the rules — which means the same index served in a different order can install a different version.

4.4 Failure Conditions

Peios / Advanced Peios / peipkg / Resolution

Resolution fails, producing no plan, when any of the following holds. Each failure names a machine-readable reason and a detail identifying the packages or constraints involved.

ConditionMeaning
UnsatisfiableA package in the candidate plan has a dependency no available package satisfies
ConflictTwo packages in the proposed resulting set trigger a conflict against each other
Architecture mismatchA package in the plan is built for neither the system's primary architecture nor noarch
Version regressionAn operation would move a package backwards without authorisation
CycleA dependency cycle the resolver cannot break by ordering
Too complexThe resolver's step budget was exhausted

4.4.1 Conflicts reject rather than cascade #

A conflict fails the plan. peipkg does not offer to remove the conflicting package to make room, which is why the cross-repository conflict guard of §3.7 has nothing to gate: a low-trust package cannot cause a high-trust one to be uninstalled as a side effect, because it cannot cause anything to be uninstalled.

4.4.2 Cycles are detected after provides resolution #

Cycle detection runs on the graph that remains once provides entries have been substituted for the dependency names they satisfy. A cycle in the raw name graph that disappears once provides is resolved is not an error.

4.4.3 Bounded work #

The forward walk carries an explicit step budget and stops with a "too complex" rejection when it is exhausted, so a pathological dependency graph cannot spin indefinitely. The algorithm itself is greedy and does not backtrack, so it is polynomial in the size of the available set regardless.

The consistency and planning passes that run after the walk are not covered by the step budget. They are polynomial too, but on a very large available set they are where the time goes.

4.5 Optional Dependencies

Peios / Advanced Peios / peipkg / Resolution

An optional dependency is never included in a plan automatically.

peipkg does not carry optional dependencies into the resolver at all: they are not part of the candidate model, so there is no path by which one could be installed without being asked for. An operator who wants one names it as a goal.

A package whose optional dependencies are absent functions correctly with reduced capability. A package that does not function without an "optional" dependency has mis-categorised it — that is a required dependency wearing the wrong label.

Optional dependencies still participate in claims. A claim path declared on an optional-dependency entry is an optional claim consumer (§9.6): the dependency need not be satisfied for the declaring package to install, and the path is materialised only if and when some eligible provider holds the role.

Note

The pattern this supports: a service that would use a log sink if one existed, and ships without one. It declares the sink's runtime path as an optional claim, installs with no provider present, and the path simply does not exist. Installing a provider later materialises the path retroactively against the declaring package's declaration; removing the provider again withdraws it, with no effect on the declaring package.

4.6 Removal Cascades

Peios / Advanced Peios / peipkg / Resolution

Removing a package that other installed packages depend on would leave the system inconsistent, so peipkg does one of two things, chosen per transaction.

Refuse is the default. The removal is rejected and the dependents that block it are named.

Cascade removes the dependents too. It is requested with --cascade, and it is the operator taking responsibility for a larger change than they typed.

Removals are ordered in reverse dependency order, so a dependent is always removed before the package it depended on.

4.6.1 Blocking relations #

A removal is blocked by an installed package that depends on the one being removed, and by an installed package whose replaces targets it. Both are computed against the state the transaction will produce, so a dependent that is itself being removed in the same transaction does not block.

4.6.2 System-critical packages #

Some packages are needed for peipkg itself, or for the system, to work: peipkg's own binary and its trust anchors, and the core system packages.

The intended guard is a refusal unless the operator supplies an operation-specific override — an --allow-critical flag — which is a foot-gun guard rather than a security boundary: under the access model the operator already holds whatever authority the underlying deletions require, so the guard exists to prevent an accidental removal disabling the system, not to deny an authorised operator who means it.

peipkg has no notion of a system-critical set, no such flag, and no guard. Uninstalling the package manager is an ordinary removal.

4.7 Roots and Resolution

Peios / Advanced Peios / peipkg / Resolution

A dependency is satisfied within a specific installation root. By default that is the same root as the depending package, so a package's closure flows into the root the package occupies. A dependency's root field overrides that, naming a different one.

4.7.1 Two resolution modes #

peipkg resolves in one of two modes.

Cross-root resolution honours a dependency's root field, placing the dependency in the root it names and producing a plan whose operations are grouped by root.

Single-root resolution treats every operation as belonging to one root. A dependency carrying a root field is placed in the depending package's root instead, and the field has no effect.

install resolves cross-root. upgrade, downgrade, uninstall, and undo resolve single-root.

The consequence is that a dependency declaring a root resolves into that root when the depending package is first installed, and is evaluated against — and if missing, installed into — the depending package's root on any later upgrade or removal.

4.7.2 Top-level placement #

Where an operator names a package directly with no explicit root, the package's default_root decides where it lands. A dependency's placement is never governed by the dependency's own default_root; only by the depending package's root and the dependency's root field.

4.7.3 Cascading across roots #

Upgrading a package that is installed in several roots produces one transaction per root. Those transactions are applied in sequence and continue past a failure: a root whose upgrade fails is reported, and the remaining roots are still attempted.

5.1 Preconditions

Peios / Advanced Peios / peipkg / Installation

An install begins with a package file already fetched, the repository it came from with its trust state, the current database state, and the plan that called for the install.

The following hold before the install proceeds:

  1. The package's hash matches what the repository index recorded.
  2. The package's signature, if present, verifies against the trust set scoped to its originating repository.
  3. The package is not already installed at the same version and architecture in this root.
  4. The package's architecture is the system's primary architecture or noarch.
  5. The package's dependencies are satisfied by the installed set, by the plan in flight, or by both together.
  6. No installed package conflicts with it.

A precondition failure aborts the install, and the transaction containing it is rolled back.

5.1.1 Verification before extraction #

For a transaction containing several installs or upgrades, every package's signature and index-hash verification completes before any package's payload is extracted.

Note

This prevents a class of multi-package attack: package A is verified, extracted, and its contents then influence the verification or extraction of package B — A installs a tool B's extraction invokes, or A creates a directory whose descriptor decides where B's files land. Verifying everything first means extraction operates on a known-good set of payloads.

Within a single root, the guarantee holds: every package is provided and verified before the first is materialised.

Across roots it does not. A cross-root transaction prepares and applies each root in sequence, so one root's payload is on disk in its final location before the next root's packages have been fetched or verified.

5.2 Validation

Peios / Advanced Peios / peipkg / Installation

Before anything is staged, the package is decompressed, parsed, and checked in full.

  1. Decompress and walk the archive, enforcing the layout and ordering rules of PSPU §5.12.
  2. Parse the manifest.
  3. Parse the files manifest.
  4. Check that every payload file has an entry in the files manifest and that every entry has a file — in both directions.
  5. Check that the manifest's name, version, and architecture match what the plan selected.
  6. Validate every payload path against PSPU §5.13 and every entry type against PSPU §5.12.
  7. Validate every symlink's target against PSPU §5.17.
  8. Validate every payload entry's destination against the permitted install destinations.

5.2.1 Destinations are checked here #

Step 8 is not a duplicate of the producer's own check. A package arriving on a target system need not have been produced by a cooperating producer, so validation performed while packing says nothing about the bytes about to be written. This is where the destination rules are actually enforced.

Ancestor directories are exempt. An archive carries an explicit entry for every ancestor of the content it ships, and those are structure rather than destination claims. A directory entry is checked only when no other payload entry sits beneath it — which is the archive shape of an explicit empty directory, and is a destination claim.

Special system packages need two keys. A package declaring special_system_package has waived the producer-side layout check. That grants nothing here: peipkg refuses an out-of-layout payload unless the operator also passed --dangerously-bypass-path-restrictions. When the declaration arrives without the flag, the refusal names the refused request, so that an operator can tell "this package asked for an exemption I did not grant" from "this package is malformed".

When both keys are present, the destination check is skipped entirely for that package, with no residual denylist. A package installed this way can write anywhere, including under /lcl/policy.

5.2.2 What is not re-checked #

The determinism rules of PSPU §5.11 constrain the archive's bytes: ordering, modification times, ownership, mode, extended attributes, and header format. peipkg checks entry ordering and nothing else. A package whose entries carry a mode other than 0777, a non-zero owner, an mtime unrelated to its manifest, or extended attributes is accepted and installed.

Because extraction ignores tar modes entirely (§5.4), such a package behaves no differently on Peios. Extracted elsewhere with an ordinary tar tool, it does not.

5.2.3 Size cross-checks #

The manifest's declared installed size has to equal the sum of the file sizes in the files manifest, and a package where the two disagree is rejected. The decompression bounds of PSPU §5.27 are enforced continuously during the walk, on every chunk of output.

The cap is computed from the size the manifest declares, plus the fixed overhead allowance, subject to the absolute 4 GiB ceiling. The index's declared sizes are parsed and carried onto the candidate but are not compared against the manifest's and are not used as the bound.

5.3 Preparation

Peios / Advanced Peios / peipkg / Installation

With the package validated, peipkg computes what the install will do: which files are created, which directories are created, which side effects will be scheduled.

5.3.1 Collisions #

A payload path already owned by another installed package is a collision, and the database refuses it: the rule that two packages may not own the same non-directory path is a partial unique index on the owned-files table.

That constraint is evaluated when the transaction's database changes are written, which is at commit — after the files have already been renamed into place. A colliding install therefore fails, but only after paying the full download, extraction, and on-disk replacement, and recovery depends on the rollback succeeding.

There is no earlier check. Collisions are not detected at plan time or at preparation time.

5.3.2 Disk space #

peipkg does not check free space before staging. Exhaustion is discovered when a write fails — during staging, where rollback is straightforward, or during the apply phase, where some files have been renamed into place and some originals are sitting at their backup paths.

A transaction may span several filesystems, since installation roots and the destinations within a root can be separate mounts, so a single global free-space figure would not be the right check even if one were made.

5.4 Extraction

Peios / Advanced Peios / peipkg / Installation

Payload entries are processed in archive order. Nothing appears at a final install path during this phase.

5.4.1 Staging #

Each regular file is written to a staged sibling of its destination: a temporary name in the destination's own directory, carrying the transaction identifier. Where a destination's basename is long enough that adding the marker would exceed the filesystem's name limit, the basename is truncated to fit.

Staging in the destination's own directory is what makes the commit-time rename intra-directory, and therefore atomic and immune to failing because the staging area is on another filesystem.

Directories are created as they are encountered, and every directory the transaction creates is recorded so that a rollback can remove it again. Symlinks are created with the linkname the tar entry carried, which was validated at parse time and so is known safe by the time extraction reaches it.

5.4.2 Modes #

Extraction ignores the tar entry's permission bits. Every file is created mode 0755, and every directory mode 0755.

This follows from the format: a package's modes are all 0777 and carry no information (PSPU §5.16), so the consumer has to choose something. What it chooses is uniform and executable.

5.4.3 Security descriptors #

peipkg creates entries without supplying an explicit security descriptor, so the kernel computes one by inheritance from the parent directory at creation time.

Manifest-declared overrides are carried in the manifest and validated for base64 decodability, decoded length, and sort order. They are not checked against the payload — an override naming a path the package does not ship, or naming a symlink, or decoding to bytes that are not a security descriptor, is accepted — and they are not applied. No descriptor peipkg supplies is ever an override.

The operator-facing policy of PSPU §5.20 — surfacing each override, diffing it against inheritance, requiring confirmation for a non-official repository — has nothing to act on as a result.

5.4.4 Hashing #

Per-file content hashes are verified during the verification pass, over the same immutable in-memory bytes that extraction then reads. The package is fully hash-checked before a single staged file is written.

Extraction itself re-checks nothing. The guarantee that what lands on disk is what was hashed rests on both passes reading the same buffer, which every current caller arranges by handing extraction a reader over already-verified bytes in memory.

5.5 Path Resolution

Peios / Advanced Peios / peipkg / Installation

An install path is computed by joining the payload-relative path to the installation root, and then used as an ordinary path.

5.5.1 What is protected #

The final component of every operation is symlink-safe. A staged file is created with exclusive-create semantics, so it cannot land on an existing file. A pre-existing entry at a destination is detected without following it, and is displaced by a rename, which does not follow a symlink at either end. A symlink already sitting at an install path is therefore renamed aside rather than written through.

5.5.2 What is not #

Ancestor components are resolved by the kernel afresh, following symlinks, on every call. peipkg holds no directory descriptor across an operation and re-walks each path string at each step.

Two consequences follow.

A symlink ancestor redirects a write, with no race involved. The format's rules permit a package to ship a symlink whose target resolves into a permitted destination, and permit a different package to ship a file whose path descends through that symlink's location — each entry validates in isolation. When the second package installs, the ancestor symlink is followed, and the file lands where the symlink points rather than where the archive said. The path recorded in the database is the archive's path, so the collision constraint of §5.3 compares the wrong name and does not fire.

The window between check and commit is the whole transaction. The check for a pre-existing entry runs while the transaction's intent is being journalled; the rename that acts on it runs in the apply phase, after every package in the transaction has been downloaded, decompressed and staged. Nothing is re-validated at commit, and no descriptor pins the directory in between.

Note

The format's symlink-target rules are the first layer of the defence described in PSPU §5.26, and component-wise resolution against a pinned parent descriptor is the second. Only the first is in place. The second is what PSPU §5.26 requires of a conforming consumer, and what closes the two cases above.

5.6 Registration

Peios / Advanced Peios / peipkg / Installation

Once a package's files are staged, peipkg records what the install will mean:

  • the package's identity — name, version, architecture, and root;
  • the repository it came from;
  • the install timestamp;
  • every payload path it owns, with the type and content hash of each;
  • its manifest, stored whole, so that later operations can consult the package's own declarations without the package file.

These rows are written inside the transaction's database commit and become visible only when that commit succeeds.

5.6.1 Why the manifest is stored #

Several later operations need a package's own declarations rather than an index entry's copy of them: reconciling claims after an unrelated install, computing what a removal breaks, and re-deriving a role's claim-path set. Keeping the manifest means those operations do not depend on a repository still being configured, or still existing.

5.6.2 Ownership #

A file is owned by the package whose install created it. Ownership is what makes uninstall, peipkg owns, and the collision constraint possible, and it is recorded per path rather than per package prefix.

Directories are recorded as owned but are shared: several packages may own the same directory, and the collision constraint applies only to non-directory entries.

5.7 Pre-Existing Files

Peios / Advanced Peios / peipkg / Installation

A path may already exist on the filesystem without belonging to any installed package — left by a manual copy, inherited from a non-Peios installation, or written by something outside the package manager.

5.7.1 What peipkg does #

peipkg checks whether something exists at the install path. If it does, the existing entry is renamed aside as a backup and the staged file is renamed into place. If it does not, the staged file is renamed in.

The check is on existence alone. Ownership is not consulted, the existing content is not compared against what is about to be installed, and the operator is not asked.

The backup is discarded when the transaction commits, along with every other backup (§8.2), so the displaced content does not survive the operation.

5.7.2 The intended behaviour #

PSPU §5 leaves the handling of an unowned pre-existing file to the consumer, but the shape peipkg is designed for is three-way:

  • Adopt it when its content is byte-identical to what would be installed. Recording the path as owned without rewriting it is safe, because the disk already holds exactly the intended bytes.
  • Fail otherwise, naming the file, rather than overwriting something nobody claimed.
  • Displace it when the operator explicitly authorises the overwrite, keeping the backup rather than destroying it.

None of the three is implemented. What happens today is the middle case without the failure: overwrite, then discard the evidence.

6.1 The File Diff

Peios / Advanced Peios / peipkg / Upgrade and Removal

An upgrade replaces one installed version of a package with another. It is conceptually a single atomic operation combining an install of the new version with an uninstall of the old, arranged so that no moment leaves the system without the package's content.

The same procedure handles a downgrade. The two differ only in which version comparison applies; the format treats them identically.

6.1.1 The four categories #

An upgrade is computed as a diff between the old version's file set, read from the package database, and the new version's, read from the new package's files manifest.

CategoryMeaningAt commit
AddedIn the new set, not the oldStaged and renamed in
ReplacedIn both, with different contentOld renamed aside, staged renamed in
UntouchedIn both, with identical content hashLeft alone
RemovedIn the old set, not the newRenamed aside

peipkg does not compute the untouched category. Every payload entry of the new version is staged and renamed into place, whether or not its content differs from what is already there.

The effects are an upgrade that rewrites and backs up its whole payload rather than the changed part of it, inode and timestamp churn on files that did not change, and the configuration-file consequence described in §6.2.

6.1.2 Ordering at commit #

Added files are renamed into place. Replaced files have their original renamed aside first, then the staged file renamed in. Removed files are renamed aside.

Directories left empty by an upgrade are not removed.

6.2 Configuration Files

Peios / Advanced Peios / peipkg / Upgrade and Removal

A package's configuration under /usr/etc/ is seed configuration: the package's defaults, not the running system's effective configuration.

6.2.1 Modified detection #

When an upgrade would replace a configuration file, peipkg compares the file's current on-disk content hash against the hash recorded for it at install.

  • Unmodified — the content matches what was recorded. The file is replaced like any other.
  • Modified — the content differs. The operator's file is left in place, the new version's default is written beside it as <name>.peipkg-new, and the divergence is surfaced in the operation report.

The check applies to paths under /usr/etc/, and also to paths under a bare /etc/ — the latter only so that packages installed before the layout moved to /usr/etc/ keep their protection. It does not permit installing to /etc/, which is a merged view rather than storage.

6.2.2 What is recorded #

On the modified branch, the file peipkg writes is the .peipkg-new sibling, but the ownership row it records names the original path and carries the new version's hash.

Two things follow. peipkg verify compares the recorded hash against what is on disk, so it reports that file as modified on every run, permanently, for a file peipkg itself deliberately preserved. And the .peipkg-new sibling is recorded nowhere, so it is owned by no package, is not removed by an uninstall, and is not attributed by peipkg owns.

6.2.3 Interaction with the missing untouched category #

Because §6.1's untouched category is not computed, a configuration file whose content is identical between the two package versions is classified as replaced. Modified detection then fires on the operator's edit, writes a .peipkg-new carrying exactly the content the package already shipped, and warns about a divergence from a version that changed nothing.

6.2.4 Where this is going #

The intended end state is the one the filesystem layout describes: runtime configuration is materialised by reconciller daemons from registry state, operators do not edit configuration by hand, and an upgrade replaces seed configuration unconditionally. Modified detection is what prevents an upgrade destroying a hand-edited file until that framework exists.

The two models converge rather than compete: once reconcillers claim a set of paths, modified detection applies only to the unclaimed remainder, and reconciller-managed seed files are replaced outright.

6.3 The Upgrade Procedure

Peios / Advanced Peios / peipkg / Upgrade and Removal

6.3.1 Preconditions #

Every install precondition (§5.1) holds for the new version, except that "not already installed" is replaced by "the installed version has the same name and architecture, in the same root, at a different version".

In addition: the new version's dependencies are satisfied, or will be by other operations in the same transaction; no installed package depends on the current version in a way the new version cannot satisfy; and a downgrade carries an explicit authorisation.

6.3.2 The steps #

  1. Validate the new package exactly as an install does (§5.2).
  2. Diff the old file list against the new (§6.1).
  3. Stage every added and replaced file to a temporary sibling of its destination, verifying its content hash.
  4. Apply, at commit: rename added files in; rename replaced originals aside and the staged files in; rename removed files aside.
  5. Schedule side effects, including those implied by files being removed as well as those the new version declares.
  6. Re-register: replace the database's record for this package with the new version's identity, file list, and manifest.

6.3.3 Dependents #

An upgrade that would leave a dependent's constraints unsatisfied cannot proceed on its own. The resolver includes the dependent's upgrade — or its removal — in the same transaction, or the resolution fails.

6.4 Replaces

Peios / Advanced Peios / peipkg / Upgrade and Removal

A replaces relation expresses supersession: this package takes over from that one, typically after a rename.

An upgrade triggered by a replaces follows the ordinary upgrade procedure with the replaced package treated as the currently installed version, even though its name differs. The database record for the replaced package is removed and a record for the replacing package is created. From the system's point of view the replaced package is uninstalled and the replacing one installed; the file diff ensures no payload file is spuriously removed during the transition.

6.4.1 The guard #

A replaces declared by a package from a lower-priority repository, targeting a package installed from a higher-priority one, requires explicit operator confirmation before it is applied (§3.7). A general --yes does not satisfy it.

The guard compares repository priorities. For a package whose originating repository has since been removed, there is no priority to compare and the guard does not fire.

6.4.2 Removal interaction #

A package cannot be uninstalled while another installed package's replaces targets it, unless that package is being removed in the same transaction.

6.5 Downgrade and Undo

Peios / Advanced Peios / peipkg / Upgrade and Removal

6.5.1 Downgrade #

A downgrade is an upgrade whose new version is older than the installed one. The procedure is identical, with one additional precondition: the operator has explicitly authorised it.

The authorisation is raised by the resolver as an elevated action, and is not satisfied by the routine confirmation prompt.

Note

Explicit authorisation is required because a downgrade is unusual: it implies the operator wants to revert to a known-good earlier state, often after a failed upgrade. Making them opt in ensures the downgrade is intentional rather than the result of a misconfigured constraint.

A downgrade target has to be available from a configured repository's archive index, or from a package file already cached. Versions pruned from an archive cannot be reached without an externally supplied package file.

6.5.2 Undo #

peipkg undo reverts the effect of a previous transaction. It works by re-resolving against the archive index with downgrades permitted, and applying the resulting plan as an ordinary transaction, rather than by restoring the previous transaction's backups.

Two consequences follow from that choice. Undo needs the archive index, and therefore a reachable repository or a warm cache — it is not an offline operation. And it produces a new transaction with its own journal and its own rollback, rather than unwinding an old one, so the guarantees are the same as any other change.

6.5.3 What a version revert does not revert #

Reverting a package's version does not revert anything outside that package's payload: registry state, configuration materialised by reconcillers, runtime data under /var/, and any user data are unaffected.

Comprehensive system rollback, including state under registry control, is a higher-level concern handled by recovery snapshots. Package-level version revert covers the common case of an update breaking something.

6.6 Uninstall

Peios / Advanced Peios / peipkg / Upgrade and Removal

6.6.1 Preconditions #

The named package is installed; no other installed package depends on it unless the plan removes them too; and no other installed package's replaces targets it.

Blocked removals cascade or refuse, per §4.6.

6.6.2 The steps #

  1. Enumerate the package's owned paths from the database.
  2. Prepare the removal.
  3. Remove: rename each path aside as a backup rather than deleting it, so that the uninstall can be rolled back.
  4. Schedule side effects implied by what was removed.
  5. Deregister: delete the package's record, withdraw any role it held, and reconcile any role whose claim paths it declared (§9.7).

Backups are discarded when the transaction commits.

6.6.3 Directories #

Directory entries are skipped. A package's directories are left in place, and its removal leaves the skeleton behind.

Because deleting the package's rows removes its ownership of those directories, they become owned by nothing, and no later operation reclaims them.

6.6.4 Overlapping ownership #

Two packages owning one non-directory path is prevented by the database schema, but a degraded state — a corrupted database, a manual intervention — could produce one. peipkg does not check for it during removal, and does not surface it as a database-integrity warning.

6.7 Modified Files

Peios / Advanced Peios / peipkg / Upgrade and Removal

A file whose on-disk content no longer matches the hash recorded at install has been modified since installation — by a person, by a program, or by corruption.

6.7.1 Where the check runs #

peipkg compares recorded hashes against disk in two places.

peipkg verify does it on demand, across every recorded file, and reports what differs.

An upgrade does it for configuration files, to decide whether to preserve an operator's edit (§6.2).

An uninstall does not. Every owned path is renamed aside regardless of whether its content matches what was installed, and the operator is not told that something they customised is being removed.

Note

A modified file at removal time is either a customisation that removal destroys, or an unauthorised modification of a system file. Both are worth surfacing, and both currently pass silently.

6.7.2 The cost of checking #

Hashing every installed file at uninstall is expensive: a large package on slow storage takes seconds. The workable shape is to restrict the check to paths where customisation is expected — configuration, and locations policy names — and skip it for binaries and libraries, with the operator able to authorise removal, skip the file, or abort.

7.1 Atomicity

Peios / Advanced Peios / peipkg / Transactions

A transaction is the atomic unit of package work. Every install, upgrade, uninstall, grant, and revoke executes within one, even when it contains a single operation.

A transaction is atomic in the sense that either all of its operations succeed and become visible, or none of them visibly take effect. That holds under two kinds of failure:

  • Logical failure — a step fails, an error is reported, or a cancellation is requested.
  • System failure — power loss, kernel panic, hardware fault, at any point.

An uncommitted transaction is invisible to anything else on the system: the database does not show its operations, staged files have not replaced their targets, and no side effect has run. A transaction that completes its commit step is committed, and its operations are visible to everything afterwards.

7.1.1 The single durability boundary #

Atomicity rests on one fact: the package database is a transactional store, and the database's own commit is the transaction's durability boundary. No separate commit protocol is layered on top of it.

A crash before that commit leaves the journal's transaction pending, and recovery rolls it back. A crash after it leaves the transaction committed, and recovery has only cleanup to finish.

The transaction is never found partly committed, which is why recovery never has to complete a half-finished commit.

7.2 Scope

Peios / Advanced Peios / peipkg / Transactions

A transaction may contain any combination of installs, upgrades, and uninstalls on different packages.

7.2.1 Ordering #

Operations are ordered so that at commit time no operation depends on a package whose install has not been committed first. Forward operations are topologically sorted dependencies-first; removals are sorted and reversed, so a dependent is removed before what it depended on.

7.2.2 One operation per package #

A transaction cannot contain two operations affecting the same package. This is structural rather than checked: the resolver's world is keyed by (name, root) and emits at most one forward operation per key, deriving removals as the complement.

An upgrade is how a version transition is expressed. A hard reinstall is a removal and an install, in two separate transactions.

7.2.3 Cross-root transactions #

An operation touching several installation roots produces one transaction per root, sharing a cross-root identifier. Locks are acquired for every participating root, in resolved-path order, so that two concurrent cross-root operations cannot deadlock against each other.

Each root's transaction is prepared and committed in sequence. That has a consequence for verification (§5.1): one root's payload is in place before the next root's packages have been fetched.

Recovery for a cross-root transaction is described in §7.8, and is the one place where roll-forward exists.

7.3 The Lock

Peios / Advanced Peios / peipkg / Transactions

At most one transaction is in progress at a time in a given root.

peipkg acquires an exclusive lock before beginning. A second invocation detects the lock and fails immediately with a "transaction in progress" message rather than waiting.

7.3.1 Staleness cannot happen #

The lock is a flock(2) advisory lock on a file in the root's state directory, held by the running process. The kernel releases it when the process exits, however it exits — cleanly, by signal, or by being killed.

That makes staleness impossible by construction. There is no timeout, no liveness probe, and no process-identity comparison, because there is nothing that could hold a lock after the holder is gone.

Note

The alternative — a lock file with a recorded process identifier, a kill(pid, 0) == ESRCH liveness probe, and a timeout — is the design this avoids. A timeout-only staleness check breaks single-writer atomicity outright: a long-running but perfectly live transaction, a fifty-package install on slow storage, can be declared dead by another invocation, and both then proceed concurrently. An advisory lock is not a weaker version of that design; it is a stronger one.

A second structural guard backs it up: the database carries a partial unique index permitting at most one transaction in the pending state, so even a defect that bypassed the lock could not produce two concurrent pending transactions.

7.3.2 What the lock does not cover #

A read-only query against committed state does not take the lock. The database provides snapshot-isolated reads, so a query sees a consistent view of committed state as of the moment it began, regardless of a write committing underneath it. Listing installed packages during a long install is safe and does not block.

7.4 The Commit Procedure

Peios / Advanced Peios / peipkg / Transactions

Commit transitions a transaction from uncommitted to committed, in five steps.

7.4.1 1. Record intent #

Before any file moves, the transaction's intent is written to the journal: the set of file operations, and for each one the staged file's path and the path its displaced original will be backed up to — the backup map. Directories the transaction will create are recorded too.

The journal is rows in the package database, so recording intent is an ordinary database write.

7.4.2 2. Apply file operations #

For each operation: rename any displaced original aside as a backup, then rename the staged file into place.

Throughout this phase every change is individually reversible from the backup map. Nothing has been deleted; a replaced file is sitting beside its own destination under a different name.

7.4.3 3. Commit #

In a single database transaction, write the new installed state — the package rows, the owned-file rows, the claim holder and link rows — and mark the journal's pending transaction committed.

This database commit is the durability boundary. It is atomic, so the transaction is either fully committed or not committed at all.

7.4.4 4. Invoke side effects #

Deduplicated across the whole transaction, after the durability boundary. A side-effect failure therefore cannot roll the transaction back: the transaction is already committed, side effects are idempotent, and a failed one is reported and corrected by re-invocation.

7.4.5 5. Clean up #

Discard staged files, and delete the backups.

7.4.6 What the ordering buys #

A crash before step 3 leaves the journal's transaction pending, and recovery rolls it back from the backup map. A crash after step 3 leaves it committed, and recovery has only step 5 to finish.

Because step 3 is a single atomic database commit, and because it carries both the new state and the journal's closure, there is no intermediate state to discover.

7.5 The Journal

Peios / Advanced Peios / peipkg / Transactions

The transaction journal is part of the package database. A pending transaction is rows in the database store, not a separate file with a separate format.

7.5.1 What it holds #

Row kindContent
TransactionIdentifier, state, schema version, and for a cross-root operation the shared cross-root identifier
OperationOne per package operation: kind, package, root
FileOne per file operation: final path, staged path, backup path, action
DirectoryOne per directory the transaction created, so rollback can remove it
Commit payloadFor a cross-root transaction, the state a roll-forward would need

Recording intent and committing are ordinary database writes, and the journal inherits the database's transactional guarantees.

7.5.2 Integrity #

The database is stored under a security descriptor granting write access to the tier of principals permitted to install packages. That descriptor is the journal's integrity protection: a principal outside the tier cannot forge an entry, and one inside it already holds installation authority, so a write from within is not an escalation.

The staging area is under the same descriptor.

7.5.3 Attribution #

Claim link operations do not have an operation row of their own within an install. They are appended to the last staged package operation as a carrier, so the file rows recording a claim link change are attributed to whichever package sorted last. A standalone grant or revoke uses a synthetic operation named for the role, and is attributed correctly.

The consequence is confined to history display; recovery is action-agnostic and unaffected.

7.5.4 Versioning #

Each transaction records the journal schema version it was written under. A peipkg version that can read that schema recovers the transaction directly; one that cannot refuses, with an error naming the schema version rather than a generic failure.

That is what makes upgrading peipkg itself unremarkable (§8.5): the binary running the next recovery may be a different version from the one that started the transaction, and the version stamp is how it knows whether it can.

7.6 Side-Effect Batching

Peios / Advanced Peios / peipkg / Transactions

A transaction may contain several operations that each declare the same side effect. They are deduplicated and invoked once per transaction, after every file-level operation is complete and after the database commit.

Distinct side effects are invoked in an unspecified order. The recognised set is chosen so that order between them does not matter.

7.6.1 Timing #

Side effects are not invoked during extraction, and not once per package. Installing ten packages that all ship shared libraries rebuilds the library cache once, after every package's libraries are in place — not ten times against ten partial states.

7.6.2 What is not scheduled #

An operation that only removes files schedules no side effects, because side-effect declarations are read from the package being installed and a removal has none in flight. The removed package's manifest is in the database and could supply them.

The visible consequence: uninstalling the last package that owned a shared library leaves the library cache naming a file that no longer exists, and removing kernel modules leaves the module dependency cache stale. Both are corrected by the next transaction that does declare the relevant effect.

An upgrade is different: side effects implied by files the upgrade removed are scheduled alongside those the new version declares.

7.7 Visibility

Peios / Advanced Peios / peipkg / Transactions

The database state visible to a query reflects only committed transactions.

Reads are snapshot-isolated: a query beginning at some moment sees a consistent view of committed state as of that moment, regardless of a write transaction committing while it runs. This is a property of the store rather than of peipkg's use of it, and it is why a read-only query needs no lock.

In-progress state is not visible outside peipkg's own process. Staged files do not appear at their final install paths until the apply phase, and journal rows describe a transaction that queries do not see.

peipkg does inspect its own uncommitted state — verifying a staged file, computing what remains to apply — which is not a visibility leak.

7.7.1 Where the boundary is softer #

Two things about an in-flight transaction are observable from outside.

Staged files and backups are siblings of their destinations rather than files in a private directory, so they are visible in a directory listing under names carrying the transaction identifier. They are not at the paths anything would look them up by, but they are there.

And within the apply phase, a file is momentarily absent between its original being renamed aside and its replacement being renamed in. Anything opening that exact path in that window sees nothing.

7.8 Crash Recovery

Peios / Advanced Peios / peipkg / Transactions

Before permitting new work, peipkg checks the journal for a pending transaction.

  • No pending transaction — nothing to do.
  • A pending transaction — roll it back. Restore every displaced original from the backup map, remove the directories the transaction created, discard its staged files, and clear it from the journal.

Recovery is itself crash-safe: every action it takes is a rename from the backup map, and every step checks the current state before acting, so re-running after an interruption converges on the same result.

7.8.1 Rollback rather than roll-forward #

For a single-root transaction there is no roll-forward. Because the database commit is the single durability boundary and is itself atomic, a recovered transaction is only ever committed — in which case the database says so and there is nothing to recover — or pending, in which case it rolls back.

Note

Roll-back-only recovery is possible because the database is a transactional store. A design that applies file changes after a separate commit-intent record needs roll-forward to finish what that record promised. Here the database commit and the fact that the transaction is done are the same atomic event, so there is nothing left to finish.

7.8.2 Cross-root: the exception #

A cross-root operation commits one root at a time. Once a root has committed, its transaction is done and cannot be undone by rolling back a sibling.

Recovery of a cross-root operation therefore does roll forward. Each root's transaction persists the state a completion would need, and a root found pending after a sibling has committed is completed from that record rather than reversed.

A root found pending with no persisted payload cannot be completed and cannot safely be reversed, and recovery refuses it, leaving the operation for an operator.

7.8.3 When it runs #

Recovery runs at the head of every install, upgrade, and uninstall, before the requested work begins and under the lock. It is not something an operator invokes; peipkg recover exists, but the ordinary path is automatic.

A pending cross-root transaction discovered by a single-root operation is refused rather than recovered, and blocks all further work in that root until peipkg recover is run.

Automatic recovery emits no audit event. The recover command does, so a rollback recovered by the next ordinary install leaves no record in the audit stream while the same rollback performed deliberately does.

8.1 Transaction Rollback

Peios / Advanced Peios / peipkg / Rollback and Recovery

A transaction can be rolled back at any point before the database commit (§7.4 step 3). After that commit the transaction is committed, and what remains is cleanup rather than rollback.

8.1.1 When it happens #

  1. Any step of any operation fails — a hash does not verify, disk space runs out, a security descriptor is rejected.
  2. The operator cancels.
  3. The process terminates abnormally before commit, in which case rollback runs on the next invocation (§7.8).

8.1.2 The procedure #

  1. Discard the transaction's staged files.
  2. Leave the pending database changes uncommitted — the commit never ran — and clear the transaction from the journal.
  3. Restore every displaced original by renaming its backup back into place.
  4. Remove the directories the transaction created.
  5. Release the lock.

Side effects are not involved. They run only after the database commit, so a rolled-back transaction never reached one and there is nothing to undo.

8.1.3 Ordering #

File operations are reversed in the opposite order to the one they were applied in, and each step checks the current state before acting, so a rollback interrupted partway and re-run reaches the same result.

8.2 Backups

Peios / Advanced Peios / peipkg / Rollback and Recovery

A backup is made by renaming the displaced original aside within its own directory, never by copying it.

The old file's content is retained in place under a different name, so a backup costs no additional disk space and is produced by a single atomic rename. The journal's backup map records, for each displaced file, the name its original was renamed to.

Restoring a backup on rollback is the inverse rename. Discarding one on commit is a delete.

8.2.1 Naming #

A backup and a staged file are both siblings of the destination, in the destination's own directory, under names carrying a marker and the transaction identifier. Where the destination's basename is long enough that adding the marker would exceed the filesystem's name limit, the basename is truncated.

Two long sibling basenames that differ only past the truncation point therefore produce the same temporary name.

8.2.2 Retention #

Backups are discarded as soon as the transaction commits.

The design permits keeping them beyond commit for a configured window, to support reverting a committed transaction from local state. That is not implemented, which is why peipkg undo works by re-resolving against the archive index rather than by restoring backups (§6.5), and why undo needs a reachable repository.

8.3 Completeness

Peios / Advanced Peios / peipkg / Rollback and Recovery

A successful rollback leaves the system indistinguishable from its state immediately before the transaction began: file contents and existence as before, the package database as before, and the journal carrying no pending transaction.

Security descriptors come back with the files. A restore-by-rename preserves a displaced original's descriptor exactly, because the file was never rewritten. For newly created content the question does not arise, since the file is removed rather than restored.

8.3.1 When rollback itself fails #

A rollback can fail: an I/O error, a filesystem gone read-only, a permission change mid-operation. The intended behaviour is that such a rollback is reported as a failed rollback, the system is treated as indeterminate, and further transactions are prevented until an operator resolves it.

What happens is different. Rollback errors are discarded at every site that triggers one, and the journal's transaction is then closed as rolled back regardless.

The consequences are worth stating plainly. If a rollback fails partway:

  • the failure is not reported;
  • the transaction leaves the pending state, so the next invocation's recovery finds nothing and does not retry;
  • some originals remain at their backup paths and some new files remain at their final paths;
  • the history shows an authoritative-looking rolled-back record;
  • the database and the filesystem disagree, with nothing to reconcile them.

peipkg verify will report the affected files as modified, because their recorded hashes no longer match what is on disk. That is the only signal.

8.4 Indeterminate State

Peios / Advanced Peios / peipkg / Rollback and Recovery

A failed rollback, a corrupt journal, or an unrecoverable backup mismatch leaves the system in a state peipkg cannot reason about.

8.4.1 The intended handling #

A recovery mode with five properties:

  1. Every write operation — install, upgrade, uninstall — is refused until recovery completes.
  2. Read operations proceed but carry the indeterminate-state warning in their output.
  3. A forensic report is available on demand, identifying the pending transaction's operations, files whose on-disk content does not match the recorded hash, database records inconsistent with the journal, and any orphaned staged files or backups.
  4. An explicit resolution command accepts an operator decision: roll the pending transaction back, or accept the current on-disk state and discard the journal and backups.
  5. Resolution is a deliberate operator action and is never performed automatically.

8.4.2 What exists #

None of the five.

There is no indeterminate state as a concept: a transaction is pending or it is not. Writes are not refused after a failure; reads carry no warning; there is no forensic report; peipkg recover offers only rollback, with no way to accept the current state and discard the journal; and recovery runs automatically at the head of every operation, with no prompt.

The last point is the sharpest. Automatic rollback of a pending transaction is correct and is what §7.8 describes. But because nothing distinguishes a cleanly pending transaction from an indeterminate one, automatic resolution is the only behaviour available for both.

8.4.3 What an operator can do today #

peipkg verify re-hashes every recorded file against what is on disk and reports the differences. That is the closest available thing to the forensic report, and it is the tool for establishing what a failed operation actually left behind.

peipkg recover rolls back a pending transaction explicitly, and emits an audit event where the automatic path does not.

Beyond that, reconciling the database with the filesystem is manual: identifying files sitting at backup paths, deciding whether the old or the new content is wanted, and reinstalling the affected packages to restore agreement.

8.5 Upgrading peipkg Itself

Peios / Advanced Peios / peipkg / Rollback and Recovery

Upgrading the package manager is not a special case.

The peipkg binary is one file among a transaction's payload, staged beside itself and renamed into place like any other. The swap is a single atomic rename, so there is no half-written binary to recover from: recovery reconciles a transaction that crashed between its atomic steps, not a file caught mid-write.

8.5.1 The one wrinkle #

After a self-upgrade, the binary running the next recovery may be a different version from the one that started the transaction.

This is handled by versioning the journal format. Each transaction records the schema version it was written under. A peipkg version that can read that schema recovers the transaction directly; one that cannot refuses with an error naming the schema version, leaving the transaction for a version that can.

No immutable copy of the previous binary is required. If recovery needs the prior binary, it is already present as that binary's ordinary backup — until the transaction commits, at which point backups are discarded (§8.2).

Note

A package-manager upgrade that commits successfully but installs a defective binary is not a recovery case: there is no incomplete transaction. It is handled by running the prior binary — retained as the transaction's backup, if the transaction has not yet committed — to perform an ordinary downgrade. Once the transaction has committed and the backups are gone, the route is a downgrade using whatever peipkg is now installed, or an externally supplied package file.

8.6 Reverting a Version

Peios / Advanced Peios / peipkg / Rollback and Recovery

Reverting an installed package to an earlier version is an ordinary downgrade, and runs through the ordinary transaction machinery with the ordinary guarantees.

8.6.1 The procedure #

  1. Query the archive index for the available versions of the package.
  2. Select the desired one.
  3. Run an upgrade with that version as the new version, which requires explicit downgrade authorisation (§6.5).

8.6.2 Constraints #

The target has to be available from a configured repository's archive index, or already cached locally. A version pruned from the archive cannot be reached without an externally supplied package file.

Reverting may require adjusting dependents whose constraints the older version does not satisfy. The resolver determines that, and the plan may include further downgrades or removals.

8.6.3 Scope #

Reverting a package's version reverts that package's payload and nothing else. Registry state, configuration materialised by reconcillers, runtime data under /var/, and user data are unaffected.

Note

Comprehensive system rollback, including state under registry control, is a higher-level concern handled by recovery snapshots and other mechanisms outside the package manager. Package-level version reversion covers the common case of "this update broke something; put it back".

9.1 The Model

Peios / Advanced Peios / peipkg / Roles and Claims

A role is a virtual name several installed packages may contend to own on the filesystem, with at most one holding it. The holder's file answers the contended path through a symlink peipkg owns.

Two registry daemons can be installed at once; only one of them is /usr/bin/registryd.

The declarations are specified in PSPU §5.23. What follows is what peipkg does with them.

9.1.1 The pieces #

A role has one or more slots, each materialising one filesystem name. A slot's claim paths are where the links appear; a target is the holder's file each link points at.

The claim-path set for a slot is the union of every installed consumer's declared path for it, plus the holder's own provider-declared default path if it has one. The materialised links are the cross-product of that set with the holder's targets.

9.1.2 Links are relative #

A claim link's body is a relative path, computed from the link's location to the target: a link at /usr/bin/registryd pointing at /usr/sbin/loregd is written as ../sbin/loregd.

Only the database keeps the absolute logical target. Anything reading the link itself — an auditor, an unrelated tool — sees the relative form.

The reason is relocatability. A root assembled by the composer, or installed under an alternate root, or packed into an initramfs archive, is moved around before it is ever booted. An absolute link body would point outside it.

9.1.3 Links belong to peipkg #

A claim link is owned by the package manager, not by any package. It never appears in a package's payload and is never recorded as a package-owned path.

That is what lets two eligible providers coexist: neither ships the contended path, so the one-package-per-path rule is never engaged by the providers themselves.

9.2 Eligibility

Peios / Advanced Peios / peipkg / Roles and Claims

A package is an eligible provider of a role when it has a provides entry naming the role whose claims field declares a target for at least one slot. Only an eligible provider can hold a role.

A package that depends on a role and declares a claim path for it, without providing the role, is a consumer only: it contributes paths and can never hold.

9.2.1 What peipkg checks #

The declaration shape is validated on both sides. A consumer-side slot descriptor carries a path and no target; a provider-side one carries a target and optionally a path. A claims field on a conflicts entry is rejected outright. Slot names are validated against the package-name grammar.

Claim paths and targets are checked for structural sanity: absolute, within the length limit, lexically clean, with a non-empty first component.

9.2.2 What peipkg does not check #

Neither a target nor a claim path is checked against the permitted install destinations, and neither is subject to the payload path-syntax constraints — normalisation form, control characters, backslashes, component length.

A target is not checked against the declaring package's own payload either. The producer-side library offers that check and pekit runs it, but peipkg does not run it at install time, so a package built by anything else can declare a target it does not ship.

The visible consequences, in order of severity: a claim path outside the managed tree is materialised there, displacing whatever was at that path into a backup that the commit then discards; a target naming a path the package does not own produces a link pointing at whatever is there; and a target naming nothing produces a dangling link.

9.3 Auto-Claim

Peios / Advanced Peios / peipkg / Roles and Claims

Installing a package that is an eligible provider of one or more roles claims, by default, every one of those roles that is currently unheld. The new provider becomes the holder, and the role's claim paths are materialised against its targets.

Auto-claim applies only to unheld roles. Installing a provider of a role another package already holds does not change the holder: the new provider is installed and eligible, and the incumbent keeps the role unless the operator directs otherwise.

Note

The two halves are deliberate. Auto-claiming an unheld role means a freshly installed sole provider just works — install the registry daemon and its binary appears, with no second command. Leaving held roles alone means installing an alternative provider never silently steals a live system name out from under the incumbent.

9.3.1 Two providers in one transaction #

When a transaction installs two eligible providers of the same unheld role, the role goes to the one whose package name sorts lexicographically first.

The rule exists so that the outcome is a consequence of the inputs rather than of the order the resolver happened to place them in, which would make an install non-deterministic.

9.4 Install Flags

Peios / Advanced Peios / peipkg / Roles and Claims

Three flags modify what an install claims. Let the provided roles of a package be the roles it is an eligible provider of.

FlagEffect
--no-claimClaim nothing, including unheld roles
--claim <roles>Claim each named role, even if another package holds it
--claim-allClaim every provided role, including held ones

The claim set applied is the union of two sets:

  • the auto set — the provided roles currently unheld — which is empty under --no-claim; and
  • the force set — the roles named by --claim, or every provided role under --claim-all.

Roles in the force set are claimed even when held. Roles in the auto set are claimed only because they are unheld.

Note

The flags compose. --no-claim --claim registryd empties the auto set and forces exactly one role: it claims that role and nothing else, not even a second unheld role the package provides. This is the idiom for "claim only this".

9.4.1 Contradictions #

Two combinations are rejected as self-contradictory: --claim-all with --claim, and --claim-all with --no-claim.

--no-claim with --claim is not contradictory — it is the idiom above.

9.4.2 Naming a role the package does not provide #

A role named by --claim that nothing in the transaction provides is rejected: a package cannot hold a role it is not an eligible provider of.

The check is against every package in flight, not only the one the operator named. So peipkg install foo --claim bar succeeds when a package pulled in as a dependency of foo provides bar, and claims it for that package.

9.5 The claim Command

Peios / Advanced Peios / peipkg / Roles and Claims

peipkg claim inspects and changes holders independently of install.

FormEffect
peipkg claim <role>Report the role's holder, its materialised claim paths, and the installed eligible providers
peipkg claim <role> grant <package>Make the named package the holder, repointing every claim link
peipkg claim <role> revokeRevoke the current grant, removing the links and leaving the role unheld

The bare form lists every installed eligible provider, including the current holder.

A grant to a package that is not installed, and a grant to a package that is installed but not an eligible provider, each fail with their own error.

A revoke on a role that is not held is an error rather than a no-op.

A grant or revoke prompts for confirmation, satisfied by --yes, and executes within a transaction that rolls back on failure like any other.

9.5.1 Addressed by role #

A role is addressed by name, never by path. Because a role has one holder across all of its slots and paths, granting it moves every one of its claim paths together.

Note

Addressing by name rather than by path keeps a multi-path, multi-slot role coherent: one command moves the whole role, however many files it materialises. A path-addressed form would invite a role being half-moved.

9.6 Materialisation

Peios / Advanced Peios / peipkg / Roles and Claims

Materialisation is reconciliation: peipkg recomputes what the role's links should be from current state, and applies the difference.

9.6.1 When it runs #

Every transaction recomputes the desired link set over all post-transaction manifests and all holders — not only for roles whose holder it changed.

That is what makes retroactive materialisation work. Installing a package that merely declares a consumer-side path for a role another package already holds creates the link against the existing holder, and does not re-open the question of who holds it. Removing such a package removes the links only it declared, leaving paths other packages still declare in place.

Reconciliation is idempotent: reconciling against unchanged state produces no filesystem change.

9.6.2 Held but unmaterialised #

Holder state lives in its own table, keyed by role, and is never inferred from whether a link exists.

A role whose computed claim-path set is empty materialises no links and remains held. Installing a package that declares a path for it later materialises the link retroactively, against the holder already on record.

Note

A sole provider installed before any consumer is held but unmaterialised: it holds the role, but until some package declares a dependency-side path — or the provider itself declares a default — there is no path to point anywhere. Tracking holder state independently of link existence is what lets the later install create the link without re-deciding the holder.

9.6.3 Collisions #

Before materialising a link, peipkg checks that no installed package owns the claim path.

The check reads the ownership table as it stands before the transaction's own rows are written, which happens at commit. So a transaction that installs both a package owning a path and a provider claiming that same path sees no owner, queues both a payload operation and a claim operation for one destination, and commits both — leaving the database holding an ownership row and a claim-link row for one path.

A package that owns a directory at the claim path is waved through rather than treated as a collision, and the directory is renamed aside and replaced with the link.

The reverse direction is unguarded: nothing stops a package payload installing over an existing claim link. The link is renamed aside, the payload file takes the path, and the claim-link row survives — after which reconciliation compares its record against its own desired set, finds them equal, and never notices the link is gone.

9.6.4 Repointing #

A holder swap repoints every one of the role's links, within a single transaction.

Each repoint is performed as two renames: the old link is renamed aside as a backup, then the new link is renamed into place. The path is absent between the two.

Note

Atomic repoint matters because a claim path is typically on the critical path of a running system — a contended daemon binary may be executed at any moment. A single rename that replaces the old link outright closes the window; two renames leave it open for a full rename round-trip, on every link of the role.

9.7 Withdrawal

Peios / Advanced Peios / peipkg / Roles and Claims

When the package holding a role is uninstalled, the role's claim is withdrawn: peipkg removes the role's links within the uninstall transaction and the role becomes unheld.

peipkg does not automatically promote another eligible provider.

If other eligible providers remain installed, the withdrawal is surfaced to the operator, naming them and the command to assign a new holder:

Claim 'registryd' withdrawn — packages 'altregd', 'thirdregd'
also provide it. Run 'peipkg claim registryd grant altregd'
to assign a new holder.

Note

Withdrawal leaves the role unheld rather than auto-promoting because the replacement is the operator's choice: the remaining providers are alternatives that were deliberately not chosen while the incumbent held the role. Auto-promoting one would silently repoint a contended system name at a provider nobody selected. Surfacing the alternatives with a ready-to-run command makes reassignment one step without making it implicit.

9.7.1 Revocation is not withdrawal #

Revoking a role is the operator taking a grant away. Withdrawal is the same end state reached automatically when the holder is removed — a package withdrawing its own claim rather than an operator revoking a grant.

A revoke does not surface the remaining providers the way a withdrawal does, even though the resulting state is identical.

9.7.2 Whether withdrawal is safe #

It depends on the role's consumers. If every installed consumer reaches the role through an optional dependency, withdrawal breaks no required dependency and is informational.

If a required dependency is left with no holder for a name it hard-codes, the operator is removing something the running system needs. That is the scenario a system-critical guard would catch, and peipkg has no such guard (§4.6).

9.7.3 Ordering within an uninstall #

Because claim operations ride the last staged package operation, an uninstall of the holder deletes the target file before it deletes the link. There is a transient window within the transaction in which the link dangles — invisible to anything that only sees committed state, but visible to a concurrent reader.

10.1 Named Roots

Peios / Advanced Peios / peipkg / Installation Roots

An installation root is a self-contained filesystem tree with its own package database. The default root is the system root — the anchor. A system may define others; the motivating case is an initramfs image, built and maintained alongside the main system through the same package graph.

A package names a root, never a filesystem location. Where a root lives is the installing system's business, which is what makes a tree relocatable and what stops a package dictating layout.

10.1.1 The grammar #

A root reference is one or more segments joined by ., each matching [a-z0-9][a-z0-9_-]*. Any reference containing / is rejected as a reference.

The grammar is implemented three times — in the manifest decoder, in the command-line parser, and in the producer toolchain — and all three agree, all three reject a /.

10.1.2 Nesting is structural #

There is no parent field anywhere. A root's registry lives in that root's own database, so nesting falls out of where the registration is recorded: initramfs.subroot is resolved by reading the anchor's database for initramfs, then reading that root's database for subroot.

Registered paths are stored relative to the owning root, which is the other half of relocatability.

10.2 Registration and Resolution

Peios / Advanced Peios / peipkg / Installation Roots

10.2.1 The registry #

A root's named children are rows in that root's own package database: name, path relative to the owning root, and creation time.

The registry is runtime fact rather than configuration. It records where a root actually is, so it lives with the rest of what the system knows about itself, not in the operator's configuration tree.

peipkg root add, remove, list, and show manage it. The composer registers roots declaratively from its manifest.

10.2.2 Resolving a reference #

The --root option accepts either form, and the discriminator is explicit:

  • A reference containing / is a literal filesystem path, used unchanged.
  • A reference without / is a name. It is split on . and walked segment by segment: open the current root's database, look up the segment, join its relative path, and recurse into that root's own database for the next segment.

An unregistered segment is a hard error. peipkg never creates a root implicitly.

A visited set of resolved absolute paths rejects cycles, so a registry that points a root at one of its own ancestors fails rather than looping.

A manifest may never carry the path form. The discriminator exists only at the command line, where an operator legitimately wants to install into a directory that is not a registered root — a mounted target, a scratch tree.

10.3 Cross-Root Dependencies

Peios / Advanced Peios / peipkg / Installation Roots

A dependency is satisfied within a root. By default that is the depending package's root, so a package's closure flows into the root the package occupies. A dependency's root field names a different one.

{ "name": "peiosutils", "root": "initramfs" }

declares that the dependency is required in the initramfs root, wherever the depending package lives.

10.3.1 Routing #

Every placement decision passes through one point: given a dependency and the depending package's root, choose the target root. An empty root field, or single-root mode, keeps the dependency where the depender is; otherwise the name is looked up in the live registry.

A root naming something not registered is a resolution failure — unsatisfiable, naming the root — rather than a silent fallback.

Cross-root edges are honoured in plan ordering and in the reverse-dependency check that decides what a removal breaks.

10.3.2 Which verbs route #

install resolves cross-root. upgrade, downgrade, uninstall, and undo resolve single-root, where a dependency's root field is inert and the dependency is evaluated in the depending package's root instead.

So a cross-root dependency is placed correctly when the depending package is installed, and is evaluated in the wrong root on every later operation.

10.3.3 Satisfier identity #

A satisfier is identified by the pair (name, root). The same package name installed in two roots is two independent installations, possibly at different versions, and a dependency is satisfied only by an installation in the root it names.

Note

Cross-root dependencies are what let a root be composed through the dependency graph. An initramfs package depends on ordinary packages — a shell, core utilities — and has them placed into the initramfs root, either implicitly by living there or explicitly through root. The depended-on package declares no root affinity of its own; where it lands is the depender's and the operator's choice. This generalises the fixed build-host/target split other systems draw to an open set of named roots.

10.3.4 Top-level placement #

Where an operator names a package directly with no explicit --root, the package's default_root decides where it lands. peipkg applies it once, before building the resolver's requests, and only when the operator did not pass --root — a defaulted root does not count as explicit.

If the named packages declare no default, the current root is used. If they declare exactly one distinct default, the whole install is re-rooted there. If they declare two or more different defaults, peipkg stops and tells the operator to split the command or pass --root, rather than picking one.

A dependency's placement is never governed by its own default_root; only by the depending package's root and the dependency's root field.

10.3.5 Cross-root garbage collection #

Removing the last thing in a root that required a cross-root dependency does not remove that dependency from the other root. Cross-root autoremoval is not implemented; the dependency stays until something removes it explicitly.

10.4 Composing a Root

Peios / Advanced Peios / peipkg / Installation Roots

peipkg-compose builds a populated root from nothing: offline, deterministically, without touching the host filesystem and without executing any package's code. It is the counterpart to peipkg, which mutates a live system.

It is not an image builder. An outer tool calls compose, then chroots to pack the initramfs, squashes the root, and builds the boot image.

10.4.1 The manifest #

A TOML document with a schema version, declaring:

KeyMeaning
archThe primary architecture, which becomes the composed database's recorded value
source_dateThe timestamp everything is stamped with
local_packagesGlobs of package files on the build host that join the candidate set — the bootstrap path
[[repository]]Name, base URL, priority, signature policy, trust anchors, transport allowance, minimum index version
[[root]]A name and a path, declaring a named root nested inside the output
[[package]]A name, an optional version constraint, an optional repository pin, an optional root

Unknown keys anywhere are rejected. A package pinning an undeclared repository, or placed in an undeclared root, is an error. Package identity is (root, name), so the same package may be requested in two roots.

10.4.2 The lock #

Resolution writes a lock: the pinned closure, with each package's source, absolute URL, hash, and root. It carries a digest of the manifest, so that building against a stale lock is caught.

The digest covers the packages, their constraints, and their repository pins. It does not cover root declarations or per-package root placement, so a manifest edit that only moves a package between roots, or changes where a root lives, produces the same digest — and a build without an explicit update silently reproduces the previous placement.

The lock's root key is a path relative to the output, while the manifest's root key of the same name is a name. A lock is therefore bound to one root layout.

10.4.3 The three phases #

Resolve performs the full trust ceremony for each declared repository in a throwaway database that never reaches the output, fetches the active index — and the archive index when a constraint might need historical versions — and reads any local package files into synthetic candidates. Manifest pins filter the candidate set. Resolution then runs against an empty installed set.

Elevated actions the plan implies are printed as warnings and the build proceeds, because compose runs unattended with nobody to authorise them.

Fetch downloads every package, checks its bytes against the hash the lock recorded, validates its format, and cross-checks the archive's own manifest against the lock entry. Every package is verified before any is extracted.

What is not checked is the inline signature against a trust set. The resolve phase performs the trust ceremony and then discards the trust state with the temporary database it was built in, so the build phase has hashes but no keys. The chain that remains is: the index was signed, the lock records the index's hash, the bytes match the hash. A package whose signing key has since been revoked is accepted by that chain and refused by peipkg.

Assemble buckets packages by root and, per root, validates the payload layout against the fetched bytes, resolves claims, seeds the database, extracts payloads, and materialises claim links. The whole tree is built under a temporary name and renamed into place on success.

10.4.4 What it seeds #

Into each root's database, in one transaction: the recorded architecture, a package row per package carrying the verbatim manifest, an owned-file row per payload entry, claim holder and link rows, and the named-root registrations.

Owned-file rows are written before any file is extracted, so a cross-package path collision aborts the build before anything lands.

Journal rows are deliberately left empty: a composed root has no transaction history because nothing was ever applied to it incrementally.

10.4.5 What it writes that no package owns #

Two things: a repository configuration file for each declared repository, and a license inventory naming every composed package's license and provenance.

Neither appears in the owned-file table, so neither is upgradable or verifiable by peipkg afterwards. Both land under paths a package could not install to.

10.4.6 What it deliberately omits #

No side effects — a composed root's library cache, module dependency cache, and man index are never built. No security descriptor materialisation. No audit events. No repository trust state or index cache in the output, so the composed system performs its own trust ceremony on first use.

10.4.7 Layout enforcement #

The permitted-destination check runs at compose time, against the fetched bytes rather than the producer's word for them, and the two-key rule applies exactly as it does at install: a package declaring itself a special system package composes only when the composition also grants the bypass, and a declaration without the grant produces an explanatory refusal.

10.4.8 Root-level views #

The composer synthesises no root-level runtime view. It creates payload entries, claim links, the repository configuration, and the license inventory, and nothing else. Where a boot root needs the psABI-fixed interpreter path to reach the loader before any view is mounted, that mapping is ordinary package payload from the base-filesystem package — shipped for each independent boot root, including the initramfs — rather than something the composer invents.

10.4.9 Roots with nothing in them #

A declared root that no package is placed in is registered but never created. The registration points at a directory that does not exist, and addressing that root on the composed system fails when peipkg tries to open its database.

11.1 The Recognised Set

Peios / Advanced Peios / peipkg / Side Effects

A package cannot ship code that runs at install time. It can declare that one of three standard maintenance operations is required, and peipkg invokes it.

IdentifierRebuilds
ldconfigThe shared library cache, /etc/ld.so.cache, and shared library symlinks
depmodThe kernel module dependency cache — modules.dep and its companions under /usr/lib/modules/<release>/
man-dbThe man page index, /var/cache/man/index.db or its equivalent, which apropos and whatis read

The set is closed. A manifest declaring anything else is rejected, and a duplicate within the array is rejected.

11.1.1 When each is required #

A package containing shared libraries declares ldconfig; one containing none does not. A package containing kernel modules declares depmod; one containing none does not. A package containing man pages is expected to declare man-db — a recommendation rather than a requirement, because man page lookup degrades to a filesystem scan without it.

peipkg validates the declared values against the enumeration. It does not validate them against the payload: a package shipping shared libraries with no ldconfig declaration packs, installs, and leaves the library cache stale, and a package declaring ldconfig while shipping no libraries invokes it for nothing.

The producer is where that check belongs — the payload map it would examine is already walked to derive shared-library capabilities — and neither the producer nor the consumer performs it.

11.1.2 What side effects are not #

  • Not a general install-script mechanism. The closed enumeration is precisely what prevents arbitrary code execution at install time.
  • Not a way to register a service. Service integration belongs to the higher-level artifacts that compose packages.
  • Not a way to seed registry state.
  • Not a way to apply security descriptors, which belong to file creation.

A package whose required behaviour cannot be expressed through the manifest is incomplete and cannot be installed through the package format alone. That behaviour is supplied by the artifact that composes the package.

11.2 Invocation

Peios / Advanced Peios / peipkg / Side Effects

Each side effect maps to one fixed command.

IdentifierInvoked as
ldconfig/bin/ldconfig, no arguments
depmod/bin/depmod -a
man-db/bin/mandb -q

11.2.1 Hardening #

Three properties make invocation safe against a package trying to influence it.

A fixed absolute path. The set is closed, so peipkg knows each tool's location and never searches a path variable. A package cannot shadow the intended tool, and because the location is a root-level runtime view, populating the writable stratum behind it needs separate local-administrator authority that a package does not have.

A cleared environment. Each tool runs with exactly LC_ALL=C and PATH=/bin. Nothing is inherited from the invoking context, which closes the environment-injection route.

Standard input closed. Each tool runs with its input attached to the null device.

Output is captured and length-capped, so a runaway tool cannot flood the operation report.

11.2.2 The kernel release #

depmod -a acts on the running kernel, since no release is named.

A transaction installing modules for a kernel release other than the one currently booted — which is the normal case during a kernel update, and always the case during an image build — rebuilds the running kernel's dependency cache and leaves the installed release's unbuilt, so those modules are unloadable until something rebuilds it.

A package shipping modules for two releases gets one invocation, for neither of them necessarily.

11.2.3 The root #

The tools invoked are the host's, at the host's absolute paths, with no root argument.

An operation against an alternate installation root therefore rebuilds the host's caches rather than the target's — once per participating root, in a cross-root transaction, and never for the root whose contents changed.

peipkg-compose runs no side effects at all, so a composed root's caches are never built by the composer either.

11.3 Timing and Failure

Peios / Advanced Peios / peipkg / Side Effects

11.3.1 Timing #

Side effects run at transaction commit, after the database commit, after every package in the transaction has completed extraction and registration.

They are deduplicated across the transaction: several packages declaring ldconfig produce one invocation. Distinct effects run in an unspecified order, which is safe because the recognised set is chosen so that order between them does not matter.

Running once, at the end, is what keeps the system consistent during a multi-package install: the library cache is rebuilt after every package's libraries are in place, not after each package individually against a partial state.

11.3.2 Failure #

Side effects run after the durability boundary, so a failure cannot roll the transaction back — the transaction is already committed.

A failure becomes a warning on the operation report and the transaction stands. The operation exits successfully: the packages installed, and only a cache lagged.

Because side effects are idempotent, a failed one is self-correcting. Re-invoking it — explicitly, or as part of the next transaction that declares it — reaches the correct state.

Note

Rolling a committed transaction back because a cache rebuild exited non-zero would be disproportionate. The three recognised tools are stable with few failure modes outside system-level corruption, and every one of those failure modes is recoverable by running the tool again.

11.3.3 What does not schedule #

An operation that only removes files schedules nothing, so removing the last package that owned a shared library or a kernel module leaves the corresponding cache naming something that no longer exists (§7.6).

An upgrade does schedule effects implied by the files it removed, as well as those the new version declares.

12.1 The Recipe Family

Peios / Advanced Peios / peipkg / Producing Packages

A recipe describes how to turn an upstream source tree into one or more packages. It is not a single file: pekit reads a family of TOML documents, each with its own role.

FileRole
pekit.tomlThe recipe: source acquisition, and the build, test, install, clean, and gen targets
workspace.pekit.tomlWorkspace marker: member globs, distro-wide environment, derivation policy
package.pekit.tomlShared package metadata — the base layer
<selector>.package.pekit.tomlOne emitted package per file
packages.pekit/An alternative directory holding the two above
env.pekit.toml, <name>.env.pekit.tomlA build entry's environment, wrapper, and dependency provider
<name>.keyring.pekit.tomlA secrets tree exported into the build environment
pekit.lockMachine-written source pin
<patches>/seriesThe patch series, plain text rather than TOML

12.1.1 Strict parsing everywhere #

pekit rejects an unknown key at every level, including the top level of every file, and every sub-parser carries its own closed key set.

There is no owner partition and no tolerance for a section pekit does not recognise, because there is only one tool reading these files. Adding a section a future version might own makes the recipe fail to load today.

12.1.2 The layer merge #

A package's definition is assembled from up to five layers, in order: the workspace base, the fetched source tree's base, the recipe's base, the source tree's member file, and the recipe's member file.

A scalar from a later layer overrides an earlier one when it is non-empty. A map or a slice replaces the earlier one wholesale — it does not merge key by key.

That rule has a consequence worth knowing when a base layer declares a dependency's root. The dependency constraints and the dependency roots are two separate maps built from one table, and each is replaced only when the overriding layer's own map is non-empty. A member layer overriding a dependency with the plain constraint form leaves the roots map untouched, so the base layer's root survives and is applied to the new constraint. There is no way to un-set a root a base layer declared.

12.2 The Recipe

Peios / Advanced Peios / peipkg / Producing Packages

pekit.toml has ten top-level keys.

KeyTypeMeaning
out_dirstringStage and output base, relative to the recipe root. Defaults to out
envtableEnvironment for target commands
wraptableA command wrapper applied to every target command
sourcetableWhere the source comes from
delegatebool or tableBorrow build targets, environment, wrapper, or package definitions from the fetched source tree
build, test, install, cleantablesTarget namespaces
gentableGeneration targets, with their own verification
source_packagetableWhether and under what name to emit a corresponding-source package

12.2.1 Environment #

[env] maps variable names to values. A name matches the usual shell identifier pattern, and cannot begin with the reserved prefix pekit uses for its own variables.

Declaration order is preserved, so a later variable may reference an earlier one — CXXFLAGS = "$CFLAGS ..." works because CFLAGS was declared above it.

12.2.2 Wrapping #

[wrap].command is a shell string or an argv array containing exactly one {{command}} placeholder. In argv form the placeholder is a whole argument, and cannot be the program name.

Every target command runs through the wrapper, which is how a whole recipe is built inside a sandbox or under a cross-compilation shim without each target knowing about it.

12.2.3 Targets #

A target namespace takes one of two shapes. A bare namespace — the table itself carries a command — is a single target named main. Otherwise each sub-table is a named target. Mixing the two is an error.

KeyMeaning
commandRequired. A shell string, or a non-empty argv array
needsOther targets that run first
clear_outWhether to clear the target's stage directory first. Defaults to true
dependenciesBuild-tool dependencies, in the build namespace only

Build-tool dependencies are grouped by provider — peipkg, apt, and so on — and each entry maps a capability name to a constraint string. The name is validated as a capability, so sonames and pkg-config module names are legal; the constraint is a non-empty string, with * meaning any.

These are the tools the build needs, and they drive the environment the build runs in. They are not the dependencies the resulting package declares.

12.2.4 Generation targets #

A gen target has its own shape: no needs, no clear_out, and a verification half.

KeyMeaning
commandRequired. Regenerates the artifact
verify_commandChecks that the committed artifact is up to date
verify_on_build, verify_on_testWhich targets in that namespace the verification gates
dependencies, verify_dependenciesAs for a build target; the verify set, when present, replaces rather than extends

The two gating keys are three-valued. Absent gates every target in that namespace; an empty array gates none; a populated array gates exactly those named. Setting either without a verify_command is an error.

This is how a generated file — an ABI table, a constants header — is kept honest: the artifact is committed, and a build fails if regenerating it would change it.

12.3 Sources

Peios / Advanced Peios / peipkg / Producing Packages

[source] says where the upstream tree comes from. At most one reproducible source may be declared — a version-control source or a fetched artifact — and declaring both is an error.

12.3.1 Version control #

KeyMeaning
urlRequired
refTemplated, defaulting to the version placeholder
versionsA constraint capping which upstream tags are eligible
tag_regexA pattern filtering tags during version enumeration

12.3.2 A fetched artifact #

KeyMeaning
urlRequired, templated
extractWhether to unpack the artifact
rootThe subdirectory of the unpacked tree that is the source root
versionsA constraint cap
file_regexA pattern applied to a directory listing to enumerate versions
checksumA single hash, or a table mapping version to hash

12.3.2.1 Signature verification #

A [source.url.signature] block makes upstream signature verification mandatory for that source.

KeyMeaning
urlTemplated; defaults to the source URL with a signature suffix
ofartifact or decompressed — which bytes the signature covers
key_filesRequired and non-empty: the pinned public keys
fingerprintsAn allowlist of acceptable signing fingerprints

This is an entirely separate trust system from package signing. It verifies that the upstream tarball is the one upstream published, using upstream's own keys, pinned per recipe. It has no relationship to the Ed25519 signature the resulting package carries.

12.3.3 A local tree #

[source.local].path names a directory relative to the recipe root. It is not a reproducible source and cannot be locked.

12.3.4 Patches #

source.patches names a single bare directory in the recipe root containing a series file: a plain-text list of patches, applied in order. It requires a reproducible source, since patching a local tree in place would mutate the operator's own working copy.

12.3.5 The lock #

pekit.lock records what was actually fetched: for each source, the version, the URL and content hash or the ref and commit, the signing key that verified it, and when it was locked.

It is trust-on-first-use and tamper-evident afterwards: the first fetch establishes the pin, and every later fetch is checked against it.

12.3.6 Delegation #

A recipe may delegate — declare that its build targets, environment, wrapper, or package definitions come from the fetched source tree rather than from the recipe. A delegating recipe is a thin pointer at a project that carries its own packaging, and it is how a project whose source tree already contains package definitions is distributed without duplicating them.

12.4 Package Files

Peios / Advanced Peios / peipkg / Producing Packages

Each emitted package is described by its own file, layered over a shared base.

KeyMeaning
formattar (the default) or peipkg
clear_outWhether to clear the package's stage directory first
buildsWhich build targets to stage; inferred from the file references when omitted
packageThe metadata table
filesSource reference to destination
symlinksDestination to target
excludesPatterns removed from the matched set
multipackFan this one definition into several packages
publishWhere to publish the result
dependencies, optional_dependencies, conflicts, provides, replacesThe manifest relationships
side_effectsThe declared maintenance operations
sd_overridesPath to security descriptor
claimsRole declarations

The manifest-facing lists are top-level tables in the package file rather than members of the metadata table.

A peipkg-format package additionally requires a version, an architecture, and a license — the last a distribution requirement stricter than the format's, which treats the license field as optional.

12.4.1 Metadata #

[package] carries the name, version, architecture, description, license, homepage, default_root, and special_system_package.

default_root is validated against the named-root grammar. special_system_package waives the layout check at pack time — but not the side-effect check — and grants nothing at install or compose time.

12.4.2 Relationships #

Dependencies take either of two forms:

libfoo = ">= 1.2"
libfoo = { constraint = ">= 1.2", root = "initramfs" }

A table with no constraint matches any version. The table form is the only way to place a dependency in another root; there is no string sugar for it.

Conflicts, provides, and replaces are plain name-to-string maps. Conflicts deliberately carry no root: a conflict is root-local by construction, and the consumer rejects a root on a conflicts entry outright.

side_effects is a list of strings passed through verbatim: pekit does not check membership of the recognised set, the consumer does. What pekit does check is agreement with the payload, at pack time and in both directions — see Building and signing.

12.4.3 Files #

[files] maps a source reference to a destination. The key grammar is the interesting half:

ReferenceMeans
@recipe:<path>A literal tree in the recipe directory
@source:<path>A literal tree in the fetched source
@workspace:<path>A literal tree in the workspace
<target>:<path>The staged output of a build target
:<path>The staged output of the main target
<path>Rewritten to the owning layer's prefix

Globs are supported, and a directory source maps its whole subtree.

A value may be a plain destination string, or a table with a path and an override flag. override is not a merge flag: it excludes that one entry from the layout validation, per file, invisibly in the resulting package.

That is a second producer-side waiver alongside special_system_package — narrower, but undeclared in the artifact, so a consumer sees an ordinary package that simply fails validation at install time.

12.4.4 Symlinks and excludes #

[symlinks] maps a destination to its target, with the same override option. excludes is a list of reference-grammar patterns removed from whatever the file map matched.

12.4.5 Multipack #

[multipack].enum fans one package definition into several. It takes a static list of values, or a derived form naming a path and a pattern to enumerate from. Each value binds a placeholder available throughout the definition, so one stanza can emit a package per kernel module, per locale, or per plugin.

12.4.6 Publish #

[publish.localdir] is an array of tables naming a path and whether to overwrite. It is the local development route; a real repository is published with the repository tool.

12.5 Templating and Derivation

Peios / Advanced Peios / peipkg / Producing Packages

Two mechanisms mean the shipped manifest is not simply what the recipe wrote.

12.5.1 Templating #

Placeholders are substituted throughout a package definition: {{version}}, {{major}}, {{minor}}, {{patch}}, {{prerelease}}, {{buildmeta}}, and {{multipack}}.

They apply to the metadata scalars, to the keys and values of every relationship map, to side effects, to claim paths and targets, to file references and destinations, to symlink targets, to publish paths, and to the source ref and URL.

They deliberately do not apply to a dependency's root, which names a registered root rather than something derived from a version.

The components come from pekit's own upstream version model, not from the package version model. For a package version carrying a Peios revision, the revision lands in {{prerelease}}; for a version carrying an epoch or a tilde, the model does not parse it and the components render empty.

The idiom for "this package depends on its sibling at this build's version" is a templated constraint:

libpeios = "{{version}}"

which pins the upstream version and leaves the revision unconstrained.

12.5.2 Derivation #

pekit derives capabilities from the built payload and merges them on top of what the recipe declared. A hand-written entry always wins.

Shared libraries. Sonames are read from the built objects: a library's own soname becomes a provide, and a binary's needed sonames become dependencies. Symlinks are skipped, so a versioned library and its development link do not both claim the same soname. A shared-library-shaped file carrying no soname produces a warning.

Where the workspace's symbol-version policy names a soname and a token prefix, the symbol versions a binary actually references become a version floor on that soname's dependency — so a binary using a recent C library symbol depends on a library version that has it.

pkg-config modules. Each .pc file becomes a provide named for the module, versioned from its version field. Its required modules become dependencies with ordered constraints, merged from both the public and private requirement lists. Variable references are expanded to a fixed point. An unparseable version or constraint is dropped with a warning rather than failing the build, and modules the package provides itself are subtracted from what it requires.

The consequence to hold onto: a shipped manifest routinely declares dependencies the recipe never wrote. Reading a recipe tells you what was declared, not what was shipped.

12.6 Building and Signing

Peios / Advanced Peios / peipkg / Producing Packages

12.6.1 The build #

pekit fetches or updates the source, applies the patch series, and runs the targets a package's builds list names, each into its own stage directory, honouring the dependency edges between them.

Every command runs with the assembled environment: the workspace layer, the source layer, the recipe layer, the selected environment file, and the keyring, in that order, wrapped by the recipe's wrapper if it declares one.

Generation targets run their verification where the gating keys direct, so a build fails if a committed generated artifact is stale.

12.6.2 Packing #

Packing collects the file map's matches from the stage directories, adds the symlinks, subtracts the excludes, applies templating, merges derived capabilities over declared ones, and hands the result to the packing library.

That library builds the manifest, the files manifest, and the archive according to PSPU §5, and then decodes its own output through the consumer's validators. A package that packs has already satisfied the rules a consumer applies on the way in — which is why a recipe error often surfaces as a manifest error at pack time.

The layout check runs here, over the whole file map minus any entry marked as an override.

The side-effect check runs here too, over the whole file map including overrides: an override escapes the layout rules, but a kernel module still needs indexing wherever it was declared. It enforces §5.24 in both directions for the effects whose trigger is a payload file pattern.

PayloadDeclarationResult
A .ko or .ko.* under usr/lib/modules/no depmodError
No kernel moduledepmod declaredError
A file under usr/share/man/no man-dbWarning
No man pageman-db declaredWarning

depmod is an error because §5.24 makes it a MUST and the failure it prevents is silent: a stale modules.dep makes modprobe resolve a dependency chain and then fail on a file that is not there, far from the package that caused it. man-db is a warning because §5.24 makes it a SHOULD — lookup falls back to a filesystem scan, which is suboptimal rather than broken.

Warnings are reported even when an error is also raised, so one run tells the author everything.

A side effect whose trigger is not a payload pattern is simply not in the checkable set. The rule is where the trigger is mechanical, pack enforces it, which leaves room for a future effect that depends on what a package means rather than on what it contains — the declaration stays the author's, and pack validates it rather than deriving it.

special_system_package does not waive this check, unlike the layout one. Special packages stage exotic layouts, which is why those rules let them through; what maintenance a payload needs afterwards is a separate question, and the kernel's module tree is exactly the payload that most needs depmod.

12.6.3 Signing #

The signing key is supplied through the keyring, under a well-known key name. The signature is computed over the uncompressed tar bytes preceding the signature entry and written as the archive's last entry, before compression.

A package built with no signing key configured is a conformant unsigned package, installable only from a repository whose policy permits unsigned content.

Private keys are read as raw key bytes or in a standard encrypted-key container. Neither encoding is specified by the format, which cares only about the resulting signature — but both are a real interface, since the keyring names a file that some other tool may have produced.

12.6.4 Corresponding source #

For any recipe with a reproducible source producing packages in the package format, pekit emits a corresponding-source package by default: the pristine upstream artifact, the applied patch series, and the build-controlling recipe files, laid out under the source destination.

The emitted package's name is recorded in the built package's manifest, so a consumer holding a binary can find the source that produced it.

12.7 Reproducibility

Peios / Advanced Peios / peipkg / Producing Packages

A package is reproducible when the same inputs produce byte-identical output. The format's determinism rules (PSPU §5.11) are necessary but not sufficient: they constrain what the archive looks like, not how the producer arrived at its contents.

12.7.1 What the format fixes #

Entry ordering, modification times tied to the recorded build timestamp, uniform ownership and mode, no extended attributes, canonical extended header records, and a fixed header format. Given the same uncompressed tar stream and the same key, the signature is deterministic too.

12.7.2 What it does not fix #

Compression. The level, the implementation, its version, and its frame parameters all change the resulting bytes and none is constrained. pekit pins its own choice, so pekit reproduces pekit; two producers seeking byte-identical output have to agree on compression out of band.

Manifest serialisation. The manifest's bytes are inside the archive that gets hashed and signed, so two semantically identical manifests with different whitespace produce different packages. pekit's serialisation is compact, unescaped, with a single trailing newline, with fields in schema order and a fixed rule about which optional fields are always emitted. That is pinned in the producer rather than in the format.

12.7.3 What the build has to control #

Everything the build process can observe: timestamps, file ordering, locale, environment, ambient filesystem state, and build paths.

The established techniques apply. SOURCE_DATE_EPOCH gives every build tool one timestamp to stamp its outputs with. A sealed environment — a container or a virtual machine — pins the full build dependency closure, which is itself a build input that determines the output. LC_ALL=C and TZ=UTC suppress locale-dependent ordering and time formatting. And build-path normalisation keeps absolute paths out of debug information and out of anything else a compiler embeds.

12.7.4 Verifying it #

The manifest records the build's farm identifier, its source reference, optionally the recipe tree's version-control identity and the producing tool's revision, and the timestamp. A third party with the same inputs can re-run the build and compare the output bytes.

The format supplies the inputs for that verification and does not mandate it.

12.7.5 Before publishing #

Worth checking before a package is published: that its hash matches what was recorded, that its signature verifies against the publishing key, that it installs cleanly in a fresh environment, that its dependency declarations reference packages that exist and constraints that are satisfiable, and that re-running the build from the same inputs produces identical bytes.

The cost of catching an error before publication is low; the cost of a published incorrect package is re-publication and a user-facing rollback.

13.1 The Privilege Model

Peios / Advanced Peios / peipkg / Security

peipkg holds no principal, no identity, and no access rights of its own.

It runs as the calling operator. Every file operation it performs — creating, replacing, or deleting — is checked by the kernel against the caller's token and the target's security descriptor. If the caller is authorised the operation succeeds; if not, it fails. peipkg contributes no authority.

This is verifiable in the shape of the program. There is no daemon, no broker, and no socket. Nothing in it changes identity: no user or group is assumed or dropped, no capability is manipulated, no ownership is changed on any file it writes. No privilege is requested anywhere. The only privilege its operation touches is the one audit emission consumes passively (§13.3).

13.1.1 What follows #

The authority to install a package is exactly the authority to write the directories the package installs into — an ordinary matter of security descriptors on the permitted destinations. A deployment grants installation authority by granting those write rights to whichever principals it intends to be able to install software.

A package's declared security descriptor overrides can only assign descriptors the calling operator already has the authority to assign. Where applying a given descriptor requires a particular right — WRITE_DAC for a discretionary access list, WRITE_OWNER and SeRestorePrivilege for an owner assignment — that right is held by the operator, not by peipkg. A package cannot, through peipkg, obtain authority the operator running it does not hold.

That consequence is currently theoretical rather than load-bearing, because overrides are parsed and then never applied (§5.4).

Note

An earlier design ran the package manager as a dedicated service principal holding broad system rights — a posture comparable to a root process. Peios instead makes it an ordinary unprivileged program: there is no standing principal to compromise, no privilege to confine, and the blast radius of a malicious package is bounded by the authority of whoever ran it.

The cost is that scoped, curated installation — letting a low-authority operator install one approved package without granting general write access — is not something peipkg can provide. That is the job of the higher-level roles and features layer, which can act as a privileged broker.

13.2 Blast Radius

Peios / Advanced Peios / peipkg / Security

Because peipkg holds no principal of its own, there is no standing privileged identity to confine and no privileged process to compromise. The authority exercised during an install is the caller's, and only for the duration of that invocation.

The blast radius of installing a malicious or defective package is therefore bounded, precisely, by the authority of the operator who installed it: a malicious package can do nothing the operator could not already do directly.

That makes the trust decision — which repositories an operator configures, and which packages an operator with broad authority chooses to install — the operative security boundary. Signature verification and the repository trust model exist to inform that decision. They do not substitute for it.

13.2.1 What peipkg does not do #

peipkg needs write access to the payload destinations and network access to fetch from configured repositories, and nothing beyond that.

  • It performs no kexec.
  • It loads no kernel modules. The depmod side effect rebuilds a module dependency map; it does not load anything.
  • It loads no BPF programs.
  • It writes no registry state. Its bookkeeping is a private store, and the database schema says so in as many words.

The one thing that reaches outside its own state is a side effect, and that is a fixed absolute path, with a cleared environment, from a closed set of three (§11.2).

Note

peipkg has its own thing it calls a registry: the table recording which named roots exist. That is a table inside the private store, not the system registry, and the claim above is about the latter.

13.3 Audit

Peios / Advanced Peios / peipkg / Security

Every install, upgrade, uninstall, refresh, and recovery emits an audit event.

Events go into the kernel event subsystem, KMES, through the kmes_emit system call. Because emission is a local kernel call rather than a message to a userspace daemon, it has no unreachable-destination failure mode: there is no reachability probe, no fail-closed rule, and no retention journal. Whether and when events are drained and persisted is the historian's concern, not peipkg's.

13.3.1 What an event carries #

FieldWhere it lives
Operation typeThe event's type tag
The caller's identityThe kernel-stamped header
Target packagesThe payload: name, version, architecture
Outcome, with a rejection reasonThe payload
Transaction identifierThe payload
Timestamp, UTCThe payload
Source repositoryNot carried

Identity is not in the payload and is not peipkg's to write. The kernel stamps the caller's effective token, its true token, and its process identity onto every emission, where they cannot be forged or suppressed by the emitting program. That is a stronger guarantee than a payload field: peipkg could lie about a payload field, and cannot lie about a header the kernel wrote.

The source repository is not recorded on an install or upgrade event, although it is known. A plan drawing packages from several repositories names none of them.

A committed cross-root operation emits its success event without a transaction identifier, so it cannot be joined to the transaction ledger or to the kernel's own record of the file operations it performed.

13.3.2 Event types #

TypeEmitted for
peipkg.installA successful install
peipkg.upgradeA successful upgrade — and a downgrade, and an undo
peipkg.uninstallA successful uninstall
peipkg.refreshA repository refresh
peipkg.transaction-failedA transaction that was rolled back
peipkg.recoveryA recovery-mode resolution
peipkg.authorisationAn operator authorisation record
peipkg.repo-addA repository add
peipkg.repo-removeA repository remove
peipkg.config-changeA trust-policy or transport-flag change
peipkg.claimA claim grant or revoke

Downgrade and undo are deliberately recorded as upgrades, since the set carries no downgrade type.

peipkg.config-change is declared and never emitted. A repository re-added with a weakened signature policy or an enabled transport allowance produces an event indistinguishable from a routine add, so trust-policy history cannot be reconstructed from the stream.

A refresh in which some repositories succeeded and others failed emits one event with a rejection outcome, an empty repository field, and a count in its detail.

13.3.3 Successes and failures #

A committed operation emits a success event; one that is rejected or rolled back emits a separate failure event. Rejection reasons and error text travel in the detail field.

An operator who declines at a prompt emits nothing: the transaction never started.

An automatic recovery at the head of an ordinary operation emits nothing. The same rollback performed deliberately through the recover command does. peipkg recover's own failure paths emit nothing either.

peipkg-compose emits nothing at all.

13.3.4 What emission depends on #

Emitting requires an audit privilege on the caller's token. peipkg warns and continues when emission fails, so an operator with write access to the payload destinations but without that privilege installs packages with no peipkg audit event.

On a kernel without the emit call at all, emission is silently treated as a successful no-op, so "audit is working" and "audit is absent" look the same.

Note

peipkg's own events are a semantically meaningful summary of an operation. They are not the security boundary. The authoritative record of what changed on disk is the kernel's own audit of the underlying file operations, which the calling operator cannot suppress. A forged or omitted peipkg event cannot conceal a file operation from the kernel's record — which is why the gaps above are gaps in diagnosis, not in accountability.

13.4 Operator Authorisation

Peios / Advanced Peios / peipkg / Security

Several points call for operator authorisation: a deliberate, explicit act specific to the elevated action in question, distinct from the routine prompt to proceed, never inferred or defaulted, and recorded in the audit stream.

13.4.1 What is gated #

Elevated actionGate
DowngradeA per-action prompt, audited
A low-trust provider filling a high-trust roleA per-action prompt, audited
A foreign replaces against a higher-priority packageA per-action prompt, audited
Proceeding on stale trust stateThe --allow-stale flag, audited, no prompt
Installing unsigned content under an optional policyNot gated, not audited
Enabling insecure transportNot gated, not audited
Resolving an interrupted transactionNot gated, not audited

The three prompted actions are raised by the resolver as authorizations, presented individually, and confirmed on their own terms. The authorising act and what it authorised are recorded.

13.4.2 --yes does not satisfy them #

--yes confirms the routine "apply this plan?" prompt. Authorizations are collected and confirmed before that prompt is reached, so --yes satisfies none of them.

With input closed, an authorization prompt reads end-of-input and returns a refusal, so a non-interactive invocation of an elevated action cancels rather than proceeding. That is the right direction to fail.

13.4.3 The channel is not distinguished #

An authorization prompt and the routine prompt read from the same input stream and accept the same affirmative. The distinctness the model calls for is a property of what is displayed, not of what is accepted.

A script piping affirmatives satisfies every elevated gate in a plan along with the routine one. The property survives for a person at a terminal and does not survive automation.

13.4.4 Flags outside the frame #

Three flags waive a check with a bare boolean and no authorisation record: the path-restriction bypass, and — where they exist — a critical-package override and an unowned-file overwrite. The first touches the payload layout rules directly, and none of the three appears in any audit event.

13.4.5 Where this is going #

The intended end state binds authorisation cryptographically: a fresh, kernel-authenticated authorisation from a principal holding rights beyond the operator's routine set, carrying the transaction identifier, the full operation specification, a nonce, and a timestamp; validated by the kernel rather than by peipkg; and emitted as an audit record co-signed by the authorising principal, so the trail does not rest on peipkg's own honesty.

That depends on kernel primitives that do not exist yet — an asymmetric key bound to a token, and event-payload signature verification. Until they do, authorisation is the deliberate act described above, and peipkg does not present it as the stronger guarantee.

13.5 Trust Anchors

Peios / Advanced Peios / peipkg / Security

A trust anchor is a key fingerprint an operator supplies out of band, and it is the root of everything the repository trust model builds on (§3.2).

13.5.1 Where they come from #

Two places, and only two: the --anchor option at repository-add time, and the trust-anchors key of a repository's configuration file.

The intended third place — a file installed by the base system, outside any package, carrying the official repository's anchors — does not exist, and nothing looks for one.

13.5.2 What that means #

The configuration directory those files live in is the writable local tier. So the official repository's anchors, when an image ships them, sit in mutable local state protected by that directory's security descriptor rather than in a read-only base-system location.

The configured form of repo add performs the full trust ceremony against anchors read from that directory. Anything able to write there can substitute the official repository's anchors before the ceremony runs.

Placing a rogue repository configuration is not by itself an escalation — adding a repository is an operator action, and the security descriptor on the directory is what decides who may take it. Substituting the anchors of a repository the operator believes is already trusted is a different thing.

13.5.3 The redundancy that is planned #

The intended end state distributes anchors redundantly: the base-system file, plus a registry key populated at first boot, with peipkg cross-checking every available source byte for byte, refusing repository operations on disagreement — with the base-system file authoritative — and refusing on a missing source in a rescue-media boot.

The model is additive: once the registry source exists, the cross-check applies without changing the file's role as authoritative.

Neither leg is present today. The registry source is correctly inert, since the registry it would live in is not yet available. The base-system file it would be checked against is simply absent.

13.6 Clock Dependence

Peios / Advanced Peios / peipkg / Security

Several checks depend on the local system clock: a signing key's validity window, the maximum trusted age, index staleness, and build provenance timestamps.

An attacker able to manipulate the local clock can extend a transitioning key's validity, evade a staleness check, or hide a compromise-detection window.

peipkg does not gate on clock sanity, and does not pretend to. There is no build-timestamp comparison, no time-synchronisation state query, and no override flag for a clock peipkg thinks is wrong. The clock-dependent checks assume a sane clock.

Note

The intended end state refuses operations when the clock is plausibly wrong — when the current time precedes peipkg's own recorded build timestamp, or when no reliable time source has reported a successful synchronisation since boot. A reliable source means a cryptographically authenticated time protocol, or agreement among several independent unauthenticated ones; plain unauthenticated time synchronisation, being trivially substitutable by an attacker in a network position, does not on its own qualify.

Operators in environments where clock manipulation is a concern will want an authenticated time source in use.

13.7 Threats Out of Scope

Peios / Advanced Peios / peipkg / Security

The following are explicitly outside what the package manager defends against. Each is a real concern; each is addressed elsewhere.

Compromise of an installing operator's identity. peipkg runs with the operator's authority (§13.1). If that identity is compromised, no format-level defence helps. The kernel's own audit and recovery mechanisms apply.

Side-channel attacks on installed binaries. Speculative-execution, cache-timing, and similar attacks against installed software are the kernel's concern and the software's, not the package format's.

Physical attacks on storage. A physically compromised disk can have its package database or installed files altered offline. Hash verification at use time — outside the package manager — is the appropriate defence.

Compromise of the build farm. A compromised farm can sign malicious packages with trusted keys. Detection requires independent reproducible-build verification (§12.7).

The build farm identifier recorded in each manifest can be constrained per repository as defence in depth — refusing packages from a farm not on a configured allowlist. That is not implemented, and it would protect only against a stale farm whose key was rotated but whose identifier is still recognised, or against an attacker who obtained a key without the farm's operational identity. It is not a format-level guarantee.

Network-level censorship or denial of service preventing package fetching. An operational concern.

Note

These exclusions are honest acknowledgements rather than weaknesses to dismiss. What they have in common is that each sits either below the package manager — where the kernel is the right layer — or above it, where the operator's judgement about what to trust is the only thing that can help.

14.1 An Interrupted Transaction

Peios / Advanced Peios / peipkg / Failure Modes

The system loses power, or the process is killed, partway through an operation.

14.1.1 What survives #

The transaction's journal is rows in the package database, written before any file moved, carrying the backup map. The database is a transactional store, so the journal is either there or it is not — never half-written.

14.1.2 What happens next #

The next install, upgrade, or uninstall acquires the lock, finds the pending transaction, and rolls it back: every displaced original renamed from its backup path back into place, every directory the transaction created removed, every staged file discarded, and the transaction cleared.

That happens automatically and silently. No prompt, and no audit event.

14.1.3 If the crash came after the commit #

The transaction is committed and there is nothing to recover. What is left is cleanup: staged files and backups that were never discarded.

Those are siblings of their destinations under names carrying the transaction identifier, and they are removed by the cleanup step whenever it next runs.

14.1.4 If the transaction spanned several roots #

Recovery rolls forward instead. A root found pending after a sibling committed is completed from the state its journal persisted, because a committed sibling cannot be undone.

A pending root carrying no persisted state can be neither completed nor safely reversed, and recovery refuses it — blocking further work in that root until peipkg recover is run.

A pending cross-root transaction found by an ordinary single-root operation is refused rather than recovered, for the same reason.

14.1.5 If peipkg was upgraded in between #

The journal records the schema version it was written under. A peipkg that can read it recovers the transaction; one that cannot refuses, naming the schema version, and leaves it for a version that can.

14.2 A Failed Rollback

Peios / Advanced Peios / peipkg / Failure Modes

The rollback itself fails: a write error, a filesystem gone read-only, a permission change mid-operation.

14.2.1 What is left #

Some originals restored, some still at their backup paths. Some new files removed, some still at their final paths. The database holding the pre-transaction state, because the commit never ran.

14.2.2 What peipkg does #

Discards the error, closes the journal's transaction as rolled back, and returns.

14.2.3 What that means for the next operation #

Recovery looks for a pending transaction and finds none, so it does not retry. The history shows an authoritative-looking rolled-back record. Nothing tells the operator the rollback did not complete.

14.2.4 The signal that is available #

peipkg verify re-hashes every recorded file against what is on disk. Files the rollback failed to restore will not match their recorded hashes, and will be reported as modified.

A directory listing around the affected paths shows the leftovers directly: siblings carrying the backup and staged markers with the failed transaction's identifier.

14.2.5 Getting back to a known state #

Identify the affected paths, decide whether the old or the new content is wanted, remove the leftover siblings, and reinstall the affected packages so that the database and the filesystem agree again.

14.3 A File That Does Not Match

Peios / Advanced Peios / peipkg / Failure Modes

peipkg verify reports an installed file whose content no longer matches the hash recorded at install.

14.3.1 What it can mean #

A legitimate edit. Someone changed a configuration file, or patched a binary in place.

Corruption. Storage failure, or a rollback that did not complete (§14.2).

Tampering. Something modified an installed file.

Neither, for a preserved configuration file. An upgrade that preserved an operator's edit records the new version's hash against the original path while leaving the operator's content there. That file is reported as modified on every run afterwards, permanently, and nothing about it is wrong (§6.2).

14.3.2 Distinguishing them #

The fourth case is identifiable: a file under a configuration path with a .peipkg-new sibling is a preserved edit, and the sibling holds what the package shipped.

For the rest, the recorded hash is what the package's files manifest said at install, so re-downloading the package and comparing is conclusive about what the content should be.

14.3.3 Reinstalling #

There is no reinstall verb. Restoring a file to what its package shipped means removing the package and installing it again, in two transactions, or downgrading and upgrading across the same version.

14.4 An Unreachable Repository

Peios / Advanced Peios / peipkg / Failure Modes

14.4.1 During a refresh #

The fetch fails, the failure is reported, and the previous trust state is retained untouched. peipkg does not fall back to unverified state and does not silently proceed.

peipkg refresh reports each repository's failure and exits non-zero.

14.4.2 During an install #

If the repository's trust state is within its maximum age, the cached index is used and the operation proceeds normally.

If it has aged out, peipkg attempts a refresh first. A failed refresh — or a refresh that returns the same index it already had — refuses the operation, unless the operator passes --allow-stale.

Uninstall and undo are not gated this way, so removing something and reverting a change still work offline.

14.4.3 When the cache is unusable #

A cached index that fails to load or fails to verify produces a warning, and resolution continues without that repository.

The consequence is worth watching for: a package the operator expected from that repository is resolved from wherever else it is available, at a lower priority, with only a warning to say so.

14.4.4 When a repository has been removed #

Packages installed from it stay installed, and their recorded origin stays with them. They are not marked, not flagged in query output, and not refused for upgrade.

Because their origin no longer resolves to a configured repository, the cross-repository guards do not fire for them (§3.7).

14.5 Disk Exhaustion

Peios / Advanced Peios / peipkg / Failure Modes

peipkg does not check free space before it starts. Exhaustion is discovered when a write fails, and where in the operation that happens decides how bad it is.

14.5.1 During staging #

The cleanest case. Nothing has been renamed into place, the transaction rolls back, and the staged files are discarded — recovering the space they consumed.

14.5.2 During the apply phase #

Some files have been renamed in and some originals are sitting at their backup paths.

Because backups are renames rather than copies, the apply phase itself consumes almost no additional space: the space was consumed during staging. A failure here is more likely to be a different error than exhaustion.

Rollback restores from the backup map, which is again renames, so it does not need space either.

14.5.3 During the commit #

The database commit needs space for its own write-ahead log. A failure there leaves the transaction uncommitted, and rollback proceeds normally — but a database that cannot write is a database that cannot record a rollback, which is where §14.2 begins.

14.5.4 Planning ahead #

A transaction's additional requirement is the staged new and changed content, aggregated across every operation. Backups cost nothing.

Because a transaction can span several filesystems — installation roots and destinations within a root can be separate mounts — the useful figure is per filesystem rather than a single total.

Nothing computes either figure. The manifest's declared installed size is available and is used to bound decompression, not to plan space.

14.6 A Broken Claim

Peios / Advanced Peios / peipkg / Failure Modes

A claim path is missing, dangling, or pointing at the wrong thing.

14.6.1 Missing #

The role may simply be unheld — its holder was uninstalled and nothing was promoted (§9.7). peipkg claim <role> reports the holder and the remaining eligible providers, and granting to one of them restores the link.

The role may be held with no consumer declaring a path, in which case there is nothing to materialise and nothing is wrong.

Or a package payload may have been installed over the link. peipkg does not prevent that: the link is renamed aside, the payload file takes the path, and the claim-link record survives. Reconciliation then compares its record against its own desired set, finds them equal, and never notices. The link does not come back on its own.

14.6.2 Dangling #

The holder's target does not exist. Either the target names a path the holding package does not ship — which peipkg does not check at install time (§9.2) — or something removed it.

14.6.3 Pointing at the wrong thing #

A claim path that landed outside the managed tree, because claim paths are not constrained to the permitted destinations, displaces whatever was there. The displaced file went to a backup that the commit discarded.

14.6.4 Repairing #

Granting a role to a provider repoints every one of its links from current state, so a grant — even a grant to the current holder's alternative and back — is the blunt instrument that rebuilds a role's links.

Where the database's record disagrees with the filesystem, reconciliation will not detect it, because it diffs the recorded set against the desired set rather than against disk. Reinstalling the holder is what refreshes both.

Appendix A State and Paths

Peios / Advanced Peios / peipkg

A.1 Per installation root #

LocationContent
<root>/var/state/peipkg/db.sqliteThe package database: installed packages, owned files, repositories, claims, named roots, and the transaction journal (§2.5)
<root>/lcl/conf/peipkg/<name>.repoOne repository configuration file per configured repository
<root>/var/state/peipkg/cache/The index cache: content-addressed index and signature objects, and a pointer file naming the current object per repository

The database is the only authoritative state. The configuration directory is the operator's, and the cache is disposable.

A.2 Transient names #

PatternMeaning
<destination>.peipkg-staged-<txnid>A file written but not yet committed, a sibling of its destination
<destination>.peipkg-backup-<txnid>A displaced original, a sibling of its destination
<name>.peipkg-newA package's new default beside a configuration file the operator had edited

The first two are removed at commit or at rollback. The third is permanent and is owned by nothing.

Where a destination's basename is long enough that adding the marker would exceed the filesystem's name limit, the basename is truncated to fit.

A.3 Composition #

LocationContent
<manifest-stem>.lock.tomlThe pinned closure a composition resolves to
<out>.peipkg-compose-tmpThe tree under construction, renamed into place on success
<out>/usr/share/licenses.jsonThe license inventory the composer writes, owned by no package

A.4 Side-effect tools #

IdentifierInvoked
ldconfig/bin/ldconfig
depmod/libexec/depmod -a
man-db/bin/mandb -q

Each with a cleared environment of LC_ALL=C and PATH=/bin, and with input closed.

depmod sits in /libexec rather than /bin because it is machine-facing: this and the kernel's own make modules_install are its only callers, and no documented workflow has a person running it. The fixed-path property the design depends on is unaffected — /libexec is a merged view with the same local-stratum protection as /bin, so a package still cannot shadow the tool.

A.5 Producer files #

FileRole
pekit.tomlThe recipe
workspace.pekit.tomlThe workspace marker
package.pekit.toml, <selector>.package.pekit.tomlPackage definitions, layered
packages.pekit/An alternative directory for the above
env.pekit.toml, <name>.env.pekit.tomlBuild environments
<name>.keyring.pekit.tomlSecrets, including the package signing key
pekit.lockThe source pin
<patches>/seriesThe patch series

Appendix B Audit Events

Peios / Advanced Peios / peipkg

Every event described here is emitted into the kernel event subsystem (§13.3). The caller's identity is stamped into the event header by the kernel and is not part of the payload.

These types also appear in the Peios Events Index, alongside every other event the system emits.

B.1 Types #

TypeEmitted forEmitted today
peipkg.installA successful installyes
peipkg.upgradeA successful upgrade, downgrade, or undoyes
peipkg.uninstallA successful uninstallyes
peipkg.refreshA repository refresh, successful or partially failedyes
peipkg.transaction-failedA rejected or rolled-back transactionyes
peipkg.recoveryA recovery resolved through peipkg recoveryes
peipkg.authorisationAn operator authorisation recordyes
peipkg.repo-addA repository addyes
peipkg.repo-removeA repository removeyes
peipkg.claimA claim grant or revokeyes
peipkg.config-changeA trust-policy or transport-flag changeno

B.2 Payload fields #

FieldContent
txn_idThe transaction identifier
outcomesuccess, rejection, or rollback
repoThe repository, for repository operations
detailThe rejection reason, the operation count, or the authorised action
timestampRFC 3339, UTC
packagesName, version, and architecture per package

B.3 Where events do not appear #

  • An install or upgrade event carries no source repository, although one is known.
  • A committed cross-root operation's success event carries no transaction identifier.
  • Automatic recovery at the head of an ordinary operation emits nothing.
  • peipkg recover's failure paths emit nothing.
  • Declining at a prompt emits nothing.
  • Enabling insecure transport, and installing unsigned content under an optional policy, emit no authorisation record.
  • peipkg-compose emits nothing at all.

B.4 What emission depends on #

An audit privilege on the caller's token. Without it, emission fails, peipkg warns, and the operation proceeds unaudited.

On a kernel with no emit call, emission is a silent successful no-op.

Peios Learn — documentation for the Peios project.

Built with Trail.