What an agent working here needs and the code cannot say: the rules, their reasons, and how a
change is checked. README.md says what the desktop is; task --list is the catalogue; git is
the history; issues hold what is open. Nothing here is a changelog, an incident log or a to-do
list: a rule earns its place by having a reason and a verification method, automated when
practical, and goes when its reason does.
Construct is Wolfi plus what Wolfi lacks for a GNOME desktop (recipes/), on its own kernel,
booted the way systemd's image-based distributions boot: a unified kernel image with a systemd
initrd, a read-only EROFS root under dm-verity found by its root hash, partitions of the
Discoverable Partitions Specification, A/B slots updated by systemd-sysupdate, and systemd-boot
falling back from a release that does not come up.
- Standards before our own code. Before writing something, look for how systemd or the image-based distributions (ParticleOS, GNOME OS, SteamOS, Fedora's) solve it: prefer their generator, unit, repart definition or udev rule. Depart only when it delivers required product behaviour or demonstrably lowers complexity, cost or latency; record why beside the departure.
- Performance is budgeted; stability is checked proportionally. A change that can affect boot, session latency, CPU, memory, image size, battery or exposed services measures the relevant metric against a reproducible baseline. A regression needs a stated reason and an accepted budget. Run the narrowest test that proves a behaviour, escalating to the image, USB, lifecycle and release gates when their boundary changes. An unrelated change needs neither a performance number nor the full harness.
- Design forward. Nothing is released yet: do not add compatibility shims or migrations for data with no promised external or durable format; change such a format and its readers together. Once data crosses a documented external or persistent boundary, specify its compatibility, migration and rollback behaviour. A rule here is not a fence: when a better way appears, change the rule, its reason and its verification method in the same commit.
- English, always: code, comments, commit messages, docs, unit and task descriptions. The person you work for may write in another language; what lands in the repository is English.
- Comments say why, never what the line does or what it used to be: git keeps history.
- Commits are partial and each one builds. Subject
area: what changed, the body says why. Commit locally; nothing is pushed or published without the person saying so. - Every build step is a Taskfile task. A container run, a melange build, a disk, a test: done
twice, it is a task, and the task is how it is done by people, agents and CI alike. Do not run
docker,melangeorapkoby hand for what a task does; fix the task. - Durable decisions are Go, not shell.
cmd/image(disks, the initrd, the harness, releases),cmd/construct(install, boot-check, the live user, the theme),internal/(one package per concern). Short declarative or shell glue is appropriate when it is simpler, asserted and cannot grow into a second program. The Taskfile runs steps in short blocks that assert what they made; a unit'sExecStart=is a command, not a program. - Locales are the product's. The desktop speaks every language its packages ship: build with
NLS, split catalogs into
<name>-lang(split/locales), install every-langpackage inimage/apko.yaml(image check-langsfails the build when one is missing), keepglibc-locale-*, and hard-code no language but the image's default (image/rootfs/etc/locale.conf).
- Approved cryptography only. Today that is Ed25519 for SSH keys and release signing (
image check-keyrefuses another signing key), and RSA-2048 where the firmware and TPM require it: the PCR key signs kernel images' PCR 11 policies and nothing else; Secure Boot's keys use what UEFI verifies. Change this profile only with a documented threat-model, compatibility and migration review. - The release, PCR and Secure Boot keys never enter the repository or a container. A release
signs with AWS KMS keys that never leave KMS; a person's own builds with files in
SIGNING_HOME, outside the repo. Every key is acrypto.Signernamed by a reference (internal/sign), andtask usbsigns on the host: Secure Boot's db over the root's EFI programs (systemd-boot, fwupd's capsule loader) before the root is sealed, the PCR policy, db over the kernel image, andSHA256SUMS. Public halves live inkeys/. - A certificate is made once and never again: firmware holds the certificate, not the key, and
refuses a signature under a new certificate for the same key. A KEK or db that must hold
something new is signed again under the same certificates (
task keys:kms --force);go test ./cmd/imagefails a certificate changed since origin/main. The KEK holds Microsoft's KEK CAs so that its dbx revocations apply; db holds its option ROM CAs and never the CAs for Windows or others' boot loaders (internal/sign/secureboot.go says why each). - A test build is never signed with a release's keys:
image usbrecords the test layer inbuilt.json, and the signing steps refuse a KMS key for it.image/test/(unsafe-mode shell, the broken GDM) goes in only with-test/-broken; the harness's SSH key arrives as a per-boot credential and is written nowhere. - An installed machine's
/varis LUKS2. The person's password opens it: as the TPM's PIN, sealed to the signed policy of PCR 11, or as the passphrase without a TPM. A recovery key is shown once at install. GDM then logs the person in; sudo and the lock screen ask again. - Whatever else proves the person sits beside the password, never in its place. A security key
opens the disk with its PIN and a touch, and its touch is taken where sudo, polkit and the lock
screen ask (
etc/pam.d/construct-key: one root-written mappings file, a fixed origin, nevernouserok). A finger is taken at the login and lock screens only (etc/pam.d/gdm-fingerprint);task testchecks no other PAM service takes one. - Updates come only through url-file transfers: systemd-sysupdate checks
SHA256SUMS.gpgand every digest forurl-filesources only; aregular-filesource is written unchecked. A stick is read asfile:///run/construct/update, the CDN as https. Transfers are generated (cmd/image/transfers.go), never hand-written. - Nothing reaches the machine that it did not ask for. The firewall
(
usr/lib/construct/firewall.nft, before any network) drops what comes in but replies, what IPv4/IPv6, DHCP and mDNS need, the containers' bridges, and SSH and RDP when a person turned them on; Docker publishes on 127.0.0.1 unless given an address. Open a port with its reason, never by widening a rule;task testchecks that a network cannot reach a server on every address. - No person, password or private key in the image or the repository.
construct installadds the person to the installed/etcon/var(internal/account); a root with nobody installed boots the passwordlessliveuser. root stays locked.
- The root is read-only and verified block by block. Whatever a package's install trigger
would write at boot (GSettings schemas, icon, font and MIME caches, users, hwdb, ld.so.cache) is
baked at build (
cmd/image/usb.gobake); add to it when a new package needs one. The USB disk and the ext4 test disk come from the same baked tree. - Units are enabled by preset, never by a hand-made link:
usr/lib/systemd/system-preset/anduser-preset/10-construct.presetlist what runs, everything else is off. - Mutable state lives in
/var, a partition on the root's own disk, its UUID derived from the machine id so that only its own machine designates it. The initrd mounts it and lays/etcover it before the switch. Home is/var/home;/home,/opt,/srv,/mntand/rootlink into/var, as do NetworkManager's connections, SSH host keys, Flatpaks, Docker, and firmware the image cannot carry (construct firmware, searched before the root's). A path a service writes in/etcor/usrworks only through the overlay: check it on the USB disk, not only on the ext4 test disk, andimage run -usb -poweroffsays whether a shutdown left the stick clean.
- Built with
task build -- <name>for x86-64-v3 (build/x86-64-v3.env); a recipe that setsCFLAGSappends to$CFLAGS.task rootfsrefuses apackages/that lacks what a recipe makes: apko would take Wolfi's package instead, and an override's whole point is what Wolfi's lacks. After a pull that bumps recipes, build each one it names first. - Every package is pinned in a lock, and a changed recipe goes through it.
image/apko.lock.yamlandimage/initrd/apko.lock.yamlare what apko builds: the hand-written configuration plus every package at a version, recipes' first, Wolfi's after. Onlytask lockwrites them (a bumped recipe at its build, Wolfi's as pinned, or Wolfi's newest with-all), and it builds both roots from what it wrote: a version taken from Wolfi's index without resolving can need what no other pin satisfies, and the builder found that out instead (nettle 4.0 beside a gnutls for nettle~3). Thelockworkflow runstask lock:outdated, which says what Wolfi has newer and changes nothing.image check-lockrefuses a root that installs what the lock does not pin, or Wolfi's package where a recipe's should be: apko takes the newest version across repositories, so a Wolfi bump above an override silently replaces it and everything it carried.task usbrefuses roots not built from the checkout's locks, andtest:usbbuilds them as the release does. - A recipe whose produced package can change takes a new version or epoch: the builder's cache
knows a package by its apk's name.
go test ./cmd/imagefails a changed recipe that can make the same apk as origin/main; comments outside build blocks are not a change. An override of a Wolfi package keeps Wolfi's package and subpackage names, starts from Wolfi's recipe and takes an epoch above Wolfi's; Wolfi's index can carry a version whose recipe is not on GitHub yet, so start from the newest published recipe and upstream's tag. - melange derives no dependency from an unversioned soname and no provides for libraries in
private directories; declare both by hand. Wolfi ships some pkg-config names it does not
provide; rewrite the
.pcor write the dependencies out.image checkfails any ELF whose libraries the root lacks. - A Wolfi package that is stale, broken or built without what the desktop needs is a recipe here, with the reason in its comments, not a workaround in the image.
- A fork follows its upstream through
task fork:sync,fork:checkandfork:pin(cmd/fork): the merge, the tree's checks and the pin's move are a program's; a conflict, what of the fork's own fixes to keep and the recipe's comment are a person's or the fork-merge skill's (.claude/skills/fork-merge). The weeklywatchfiles a fork behind its upstream; the weeklyforksworkflow merges the clean ones. A fork's branch is pushed by a person: the recipe clones it from GitHub. build/versions.envpins what every build fetches from outside (the Wolfi base image, the kernel, linux-firmware, microcode, Grype); a bump is checked like any other change.
- The base is Arch Linux's configuration for the same kernel (
kernel/Dockerfile), with general-PC drivers as modules unless the supported-hardware policy and measured image or boot budgets say otherwise. Adopt an Arch kernel tag after its configuration assertions and relevant compatibility and performance checks pass; availability alone is not a release criterion.kernel/platform.config(boot, systemd, hardening) andkernel/desktop.config(a PC's hardware) are merged over it and every line of both is asserted after olddefconfig: fix a dropped symbol's dependency, do not delete the line. A PC is not a server in a controlled environment: do not close what a person's hardware or tools may need. - Modules are signed by a key that exists only inside the build, with integrity lockdown.
- Compiled with Wolfi's Clang, ThinLTO,
-march=x86-64-v3, AutoFDO and Propeller for a profile from real hardware, full dynamic preemption, sched_ext (scx_lavd at boot). CachyOS's compiler side, none of its patches. - Firmware is the pinned linux-firmware, all of it but what no PC carries, zstd; microcode stays uncompressed in the UKI's early cpio.
Validation is tiered. Run the fast checks for the changed code or configuration first, then the row for each affected boundary. A release runs every applicable gate. Do not run a slower unrelated gate merely because it exists; do not omit a boundary gate because a faster one passed. Compare performance reports only with a successful comparable baseline, and state the metric and budget in the change when one applies.
| changed | run | and look at |
|---|---|---|
| Go | task check (what CI runs on a pull request) |
a test for the behaviour, not for the lines |
| a recipe | task build -- <name>, task lock (which builds both roots), task disk -- -test && go run ./cmd/image check |
the package's files, .PKGINFO depends/provides, and the lock's diff: the recipe's package and nothing of Wolfi's |
| an affected image or package behaviour | task test (ext4 disk, kernel given directly) |
the report and relevant screenshots in _output/test: a PASS means a window opened, not that it looks right |
| a shell extension, or the shell itself | task test |
shell-startup.txt (the session's shell from its unit to the last extension loaded, each extension's own load) and idle.txt (the session at rest: CPU, wakeups, what is busy), against the last build's |
boot, the initrd, /var, /etc, units, the read-only root |
task test:usb (UEFI, a USB stick, the verified root) |
the same, on the disk a machine boots, and boot.txt against the last build's |
| install, updates, signatures, boot counting | task test:lifecycle |
install onto NVMe, forged sticks refused, update from a URL, rollback from a broken release, /var and /etc kept |
| the kernel | task kernel:config, then task kernel |
the assertion's output |
| packages, their versions | task usb, then task scan (Grype, build/grype.yaml) |
_output/scan/report.md: a Critical or High a version fixes fails it; a rule that keeps one carries its reason |
| the boot's look (splash, Plymouth) | go run ./cmd/image boot -usb -shots 30 |
the frames in _output/boot |
Never pipe a gate's exit status away: redirect to a file and read the status.
To use it as a person does: task play. The graphical installer is UI only: what it shows comes
from construct disks --json and construct install --json, and test:lifecycle installs through
exactly its command.
For a boot that misbehaves: go run ./cmd/image run [-usb | -vars -nvme -sticks] -- '<cmd>' runs
a command as root in a booted VM; the harness saves failed units' logs in units/; task usb -- -append <option> adds kernel options for one build; rd.systemd.debug_shell gets a shell in the
initrd, whose units log to the journal.
- A release is built on the release builder, never on a person's machine:
task releaselaunches an EC2 instance for the pushed commit (Construct-Linux/infra), which runs the harness on a test build of the same commit, signs with KMS, scans, and terminates. A check that fails stops the release.task releaserefuses a committask test:usbhas not passed on this machine: the builder finds the same failure two hours and a machine later. - Versions are CalVer,
vYYYYMMDD.N; files carry them without the v. - Machines follow channels: a channel is a signed SHA256SUMS of its newest releases at the
update CDN. A release goes to
testing; promote it only after its stated soak criterion and required lifecycle evidence pass, and record that evidence with the promotion. Thentask release:promoteoffers it tostable;task release:withdrawtakes a bad one out before machines take it. The criterion may be elapsed time, successful boots, a cohort, or a combination appropriate to the release's risk. - Issues are where problems go, and where open work goes: the issue forms ask for
construct report, the weeklywatchworkflow files security fixes, and pins and forks behind upstream, Dependabot opens the Go modules' and the actions' bumps.