# Private unsigned UAT ISO preparation The operator requires the **same qualified candidate on Framework and Yaya and an ISO for a physical test install before approving signed OTA/ISO/Companion or other publication**. `scripts/build-iso-uat.py` prepares the private ISO handoff. It does not deploy, sign, request keys, generate an OTA manifest, change the public feed, or authorize publication. The signed release helpers retain their gates. Existing APK signatures are verified for identity/continuity; none are created. ## Inputs required from the integration/qualification owner 1. A clean, reviewed, frozen Git commit containing all integrated lanes and this tooling. Full commit identity and matching Cargo/npm versions are mandatory. No release tag is required for UAT. Dirty/untracked changes are refused. 2. A native x86-64 backend built from that commit, with retained build commands, toolchain/lockfile and native qualification evidence. ELF inspection alone does not establish build provenance. 3. The qualified flat frontend tar.gz for that commit, including dashboard, `/aiui/` build with exact `BUILD-INFO` commit, Companion APK/metadata, and `archipelago-runtime/RELEASE-PAYLOAD.json`. Produce the runtime overlay with `release_payload.py stage --version VERSION --commit FULL_COMMIT` as described by that helper's CLI; canonical archive modes are directories 0755/files 0644. This helper consumes that archive; it never substitutes a locally served UI. 4. Qualified `archy-reticulum-daemon`, `archy-rnodeconf` and `archy-esptool` binaries, with source/toolchain provenance in the evidence. Esptool's existing builder self-test remains mandatory. 5. The locally qualified LND Tor OCI archive, its `identity.json` and `final-source.json` from `LND-TOR-ARTIFACT-20261101.md`. This is a local import binding, not a registry publication. The exact imported digest (not the pre-export builder digest) must match both `apps/lnd-tor/manifest.yml` and native `QUALIFIED_IMAGES`. All six recorded source files must still match. 6. A retained qualification evidence file (or an archive of the evidence), with actual source/native/frontend/Companion/composition gate results and exact hashes of all eight artifact/proof inputs. Record review/proposal dispositions, unresolved ledger gates, and why this candidate is ready for **unsigned UAT**, explicitly distinct from physical/operator release acceptance. The qualification owner supplies and reviews this attestation; the helper cannot authenticate a human review or reconstruct native build provenance from an ELF header. Write a private JSON receipt with this shape. The angle-bracket values below are placeholders, **not hashes or qualification claims**; replace every one using real retained evidence (`sha256sum`), never copy an earlier candidate's hashes. ```json { "schema": "archipelago-unsigned-uat-input-v3", "qualification": "qualified-for-unsigned-uat", "reviewed_by": "", "source_commit": "", "version": "", "backend": {"path": "", "sha256": ""}, "frontend": {"path": "", "sha256": ""}, "reticulum": {"path": "", "sha256": ""}, "rnodeconf": {"path": "", "sha256": ""}, "esptool": {"path": "", "sha256": ""}, "lnd_tor_oci": {"path": "", "sha256": ""}, "lnd_tor_identity": {"path": "", "sha256": ""}, "lnd_tor_sources": {"path": "", "sha256": ""}, "companion": { "package_name": "com.archipelago.app.debug", "versionName": "", "versionCode": 58, "sha256": "", "metadata_sha256": "", "certificate_sha256": "" }, "evidence": {"path": "", "sha256": ""}, "kernel": { "pin": { "architecture": "amd64", "package": "linux-image-6.12.113+deb13-amd64", "version": "6.12.113-1", "source": "linux-signed-amd64", "archive_sha256": "", "vmlinuz_sha256": "" }, "archive": {"path": "", "sha256": ""}, "payload_receipt": {"path": "", "sha256": ""}, "evidence": {"path": "", "sha256": ""} } } ``` Keep artifacts and evidence outside the source checkout. A different source commit or artifact requires fresh qualification; a version substring is not source provenance. The final receipt includes the input attestation verbatim and its hash so the later Framework/Yaya deployment owner can match exact bytes. ## Build invocation (only after those inputs exist) From the clean frozen checkout, set `AAPT` and `APKSIGNER` to the installed Android SDK tools. Requires Python 3.12+, Git, sudo, the archived builder dependencies, xorriso, squashfs tools, QEMU and Debian OVMF. The existing builder uses privileged containers, authenticated APT repositories and network downloads. Run on a dedicated disposable build environment; its container engine must also enforce the CPU2/8G budget (a daemon's containers do not automatically inherit the calling user scope). No build is authorized on the current dirty integration worktree. ```bash export AAPT=/home/archipelago/Android/Sdk/build-tools/35.0.0/aapt export APKSIGNER=/home/archipelago/Android/Sdk/build-tools/35.0.0/apksigner systemd-run --user --scope -p CPUQuota=200% -p MemoryMax=8G -p MemorySwapMax=0 \ flock /tmp/opencode/release-heavy.lock \ python3 -B scripts/build-iso-uat.py \ --qualification /absolute/private/qualified-uat.json \ --output /tmp/opencode/unsigned-uat-unique-run ``` Use the actual verified `versionCode` too; 58 above is the currently qualified Companion, not a permanent version allowance. The complete `companion` object must equal SDK-verified source identity plus the metadata hash. V1 and V2 input receipts are refused: V3 additionally requires the exact qualified kernel binding. The current x86-64 UAT wrapper requires amd64; it cannot default a missing pin. ## Exact qualified kernel contract `kernel.pin` has exactly the six keys shown above. Package, Debian version and signed source must agree with the architecture. Archive/payload/evidence paths must be absolute regular files with matching hashes; the payload receipt's package/version/source/archive and vmlinuz hashes must match the pin. Supply the real retained qualification evidence; this unsigned receipt is not itself a Debian signature or operator install approval. The `.deb` is retained and hash-bound as an input, not installed as a fallback that bypasses current authenticated indexes. The UAT wrapper passes a canonical pin file by explicit `--uat-kernel FILE` CLI, paired with `--unsigned-uat-oci`. The archived builder clears ambient UAT pin variables and refuses incomplete/duplicate/invalid CLI pins before build mutation. Generic signed builds without these options keep the existing CVE floor policy. For UAT, both the target rootfs and live installer: - run normal authenticated `apt-get update`, then require the exact version, architecture, Debian origin/source and archive hash in those current indexes; - apply a specific positive pin for the one qualified package/version, negative pins for its other versions and **all other linux-image packages**, including generic metapackages; APT simulation must select no other kernel; - install the versioned package with `=VERSION`, never the generic metapackage; UAT debootstrap omits the kernel altogether until the policy is in place; - keep that policy through recommends and full-upgrade, verify final installed package set, selected `/vmlinuz` and `/initrd.img` links, kernel bytes, module release and actual initramfs contents; retain authenticated package provenance and `boot-binding.json` hashes after generation; - retain APT package record, plan and signed InRelease files with original dates. No GPG/hash/Date/Valid-Until check is disabled, no unsigned mirror is used, and missing exact versions fail rather than substituting a newer fixed kernel. The exact pin bytes and validation helper enter the rootfs recipe identity. Every reused UAT rootfs tar is additionally checked for exact dpkg/package/receipt, all receipt payload hashes, selected links, initrd hash and its module contents; a copied stamp cannot authorize a stale cache. The final extracted ISO repeats target/live exact binding, checks `/live/vmlinuz` and `/live/initrd.img`, and both BIOS/UEFI logs must identify the matching kernel build. `.115` can remain a known fixed generic kernel while failing a candidate whose qualified profile is `.113`. The extracted target and live receipt file maps must also equal the supplied qualified archive payload map (4,254 regular files for the current `.113` package), so a changed module plus a rewritten local receipt cannot qualify itself. `UNSIGNED-UAT.json` output schema is now `archipelago-unsigned-uat-result-v2`, with `qualified_kernel` and `live_boot_binding`. Physical install acceptance follows test-ISO delivery; it is not a prerequisite for building this unsigned test ISO. The output directory must be new and outside source. Failures retain logs and intermediates there; **absence of `UNSIGNED-UAT.json` means no successful UAT artifact handoff**. Do not use an ISO left by a failed run. There are no skip-smoke, skip-boot, signing, deployment or publication options. An existing output is never overwritten. The archived builder is run from a fresh clone, excluding ignored local binaries/dist/caches. Its global container cache/build-counter behavior is another reason to use the dedicated build environment. Required gates before a success receipt: - Pre/post clean source and version identity; hash-checked staged inputs and retained qualification evidence; native ELF and strict frontend archive checks. - Existing Companion aapt/apksigner identity/version/certificate/scheme checks. - OCI archive hash, every blob hash, all descriptor sizes/media types/digests, the single linux/amd64 manifest/config, UID100/GID100, and every decompressed layer diffID. Extra blobs, stale builder digests, mutable source tags and source receipt drift fail before the build. - Exactly one new ISO from the fresh clone; real mount-level ISO smoke test. - xorriso extraction and exact backend, full frontend (including APK/JSON and runtime overlay), and radio helper byte comparisons. Extra web files fail. - Actual rootfs tar kernel/module receipt hashes and fixed Debian package floor; actual live squashfs receipt/hash validation and equality with `/live/vmlinuz`. These checks reuse the kernel lane's current policy without changing it. - Actual BIOS **and** UEFI ISO boots into userspace in separate disposable QEMU disks, with serial evidence of the exact verified fixed kernel release/build. Boot sanity is not installation or installed-node acceptance. Existing initrd and boot plumbing checks remain part of the mandatory smoke test. - ISO hash unchanged through verification. Output is named `archipelago-UNSIGNED-UAT-VERSION-COMMIT-x86_64.iso`, with an unsigned `.sha256` and `UNSIGNED-UAT.json` recording source tree, artifacts, kernel and log hashes. ## Local OCI delivery and appointed-runtime import The transferable `candidate/` directory contains the exact backend/frontend, radio helpers, original qualification/evidence, all three OCI inputs, and `unsigned-uat-oci/`. That sub-bundle contains the archive, identity/source proofs, source snapshots (including native admission), narrow importer/validator, and a hash inventory in `BUNDLE.json` bound to the candidate commit and Companion. The final UAT result records its actual SHA256. The unsigned builder passes an explicit `--unsigned-uat-oci` argument to the archived builder. It is **not an inherited environment option**, so signed wrappers retain their existing behavior. The ISO gets an exact copy at `/archipelago/unsigned-uat-oci`; post-build extraction verifies every byte and revalidates the OCI proof. It is deliberately outside `container-images/` so the legacy default-store loader cannot pick it up. Both the archived auto-install path (`/mnt/target`) and the separate `install-to-disk.sh` path (`/mnt/archipelago`) validate and stage it under `/opt/archipelago/unsigned-uat-oci`. Staging reports `staged-import-deferred` and never invokes Podman, enables a service, or changes app intent. A differing already-staged candidate is preserved and causes failure instead of overwrite. After source/candidate qualification and separately authorized node deployment, the D09 admission owner supplies the **actual** runtime configuration and storage configuration hashes, and the packaging receipt supplies the bundle hash. Run under the shared heavy lock and CPU2/8G limits: ```bash sudo python3 -B /opt/archipelago/unsigned-uat-oci/import-uat-oci.py import \ --bundle /opt/archipelago/unsigned-uat-oci \ --bundle-sha256 "$QUALIFIED_BUNDLE_SHA256" \ --runtime-sha256 "$APPOINTED_RUNTIME_SHA256" \ --storage-sha256 "$APPOINTED_STORAGE_SHA256" ``` Retain stdout as the per-node import receipt. Do not invent these hashes or obtain the storage hash from root's home. The root coordinator uses installed `c01_runtime.load_config()` and a live `RuntimeLease`; it pins the lease's actual mount namespace, enters that namespace, drops to its appointed runtime UID/GID without supplementary groups, and supplies that runtime's HOME/XDG/config. Storage declarations are checked **before Podman**; explicit graphroot/runroot/ driver and `--remote=false` prevent default-store/remote fallback. Actual Podman info must prove the rootless config file, graphroot, runroot and driver before and after import. Runtime/storage hashes and the live lease remain checked. Only `load`, digest-derived local `tag`, and exact digest `image inspect` are performed; imported manifest digest, config ID, platform and identity must match. No image is pulled or pushed, and no container, unit, wallet, app record, stop, uninstall, pause or hold decision is changed. A local image cache entry grants no permission to activate an app. Normal admitted lifecycle remains authoritative. Missing installed runtime helpers, missing/invalid appointment, unavailable live namespace, absent storage config, or a mismatch yields **exit 75 / DEFER**, with no default-store retry. If failure occurs after loading, it may leave that exact image cached; no success receipt is emitted and no automatic cleanup touches other images. Fixture bundles with null source identity cannot be live-imported. The installer always stages first; it cannot manufacture D09 admission at install time. Installed-runtime import visibility and normal lifecycle are separate qualification requirements, not implied by disposable-store tests. Smoke failures, including legacy smoke assertions that no longer match a reviewed candidate, must be investigated by their owner; do not skip or relabel them. ## Handoff and approval order The separately authorized deployment owner installs the exact receipt-bound backend/frontend/Companion candidate on **both Framework and Yaya**, retaining rollback, wallet/data/uninstall-intent and no-repayment safeguards. Record served and installed byte hashes plus actual feature/composition/restart/recovery checks. Give the operator the unsigned ISO and checksum for a physical install; record live, installed and running fixed-kernel checks separately, including reboot. All outstanding regression-ledger requirements remain open until evidenced or explicitly dispositioned by the operator. Only after these three UAT surfaces and explicit operator approval may the lead proceed with signed OTA/ISO/Companion/etc. Existing annotated-source, mirror parity, signature and public-feed checks still apply to that later pipeline. This UAT receipt is not a signed release manifest and must never be promoted into a feed. If the signed release is rebuilt, byte differences require renewed qualification and, where affected, renewed UAT; do not assume a shared version proves equality. ## Tooling verification ```bash systemd-run --user --scope -p CPUQuota=200% -p MemoryMax=8G -p MemorySwapMax=0 \ flock /tmp/opencode/release-heavy.lock \ python3 -B scripts/tests/test_unsigned_uat_iso.py -v ``` These tests create a real **nonbootable synthetic payload ISO** using xorriso, extract and compare it, and exercise tamper/failure paths. Synthetic kernel receipts and boot-log strings test validation only. They do not establish Debian APT authentication, candidate ISO smoke/boot, installed-node or operator UAT. Additional focused suite: `PYTHONPATH=scripts/tests python3 -B -m unittest -v test_uat_kernel test_uat_oci test_unsigned_uat_iso test_kernel_security`, under the same resource/lock wrapper. Kernel tests use real APT policy evaluation, actual cpio/initramfs inspection, cached tar payload tampering, builder CLI execution and rendered rootfs/executed debootstrap argument checks; these are not full ISO boots. `scripts/tests/qualify_uat_oci.py --evidence E --output /tmp/opencode/NEW-RUN` verifies the real archive and SDK-verified Companion, carries the real bytes through a nonbootable ISO and offline target staging, then uses the same image import/inspection routine in a fresh explicitly configured rootless VFS store. It creates no containers, leaves its store and receipts for inspection, and marks `source_qualified=false`, `full_candidate_built=false`, `live_d09_import_tested=false`.