Repository navigation
Update: download, staging self-check, activation worker, rollback - #7
immakermatty wants to merge 10 commits into
Conversation
Second slice of the update core (docs/update.md): `lazurio update` now checks, downloads, stages and activates; `--download-only`, `update rollback` and `self-check` are new. - Download: signed identity first (target, version, digest, updater contract, schema read-compat), then a resumable ranged transfer into scratch with one total deadline, bounded retries, idle detection, a disk-space check, and length + SHA-256 against signed targets. Progress goes to a callback and to observed.json; reading the observation collapses a transient status whose operation is gone. - Stage: the candidate is run by its own path in `self-check --json` mode with an empty environment and a hard timeout; only then is its directory renamed into versions/. An already staged version is re-hashed and re-checked. - Activate: selector swap by atomic rename, the durable activation record (switching -> confirming -> removed), a worker started from the SELECTED immutable executable in its own session (a transient unit under systemd), the step lock released while waiting, a worker-liveness flock, and resumeActivation() for any later start. Service adapters: none (confirmed by the new executable's self-check through the selector) and systemd-user (restart + readiness file written by the Launchpad: digest of its own executable, pid, start time, stable for a period). Retention after confirm. - Owner decisions: channel floors removed (TUF protects the channel document; the rollback test now replays an older repository state), damaged trust files never wedge, `response-too-large` and `lock-unsupported` have their own codes (errno read through FFI). Tests compile real executables A, B and C of a TEST-ONLY product entry point and drive bin/lazurio against the signed loopback repository, including kill -9 of caller and worker in each phase and two concurrent updates. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… deviates Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| policy?: Partial<ActivationPolicy>; | ||
| }): Promise<ResumeResult> { | ||
| const { base } = input; | ||
| if ((await readActivationRecord(base)).kind === "absent") |
There was a problem hiding this comment.
🟡 Medium update/activate.ts:320
resumeActivation can return none while an activation worker already holds activation.lock but has not written its record yet. A concurrent performUpdate can therefore acquire stepLock, stage a different --download-only candidate, and report ready; when the first worker later calls confirm() and applyRetention(), it deletes that newly staged version. Acquire and verify activation.lock before the absent-record fast path, then re-read the record while holding the lock so this startup window is serialized.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/activate.ts around line 320:
`resumeActivation` can return `none` while an activation worker already holds `activation.lock` but has not written its record yet. A concurrent `performUpdate` can therefore acquire `stepLock`, stage a different `--download-only` candidate, and report `ready`; when the first worker later calls `confirm()` and `applyRetention()`, it deletes that newly staged version. Acquire and verify `activation.lock` before the absent-record fast path, then re-read the record while holding the lock so this startup window is serialized.
| // Read-only and executable for the owner: the bytes are final. | ||
| await chmod(executable, 0o500); |
There was a problem hiding this comment.
🟠 High update/stage.ts:65
After a power loss, the published version can retain the artifact's original non-executable mode even though stageCandidate called chmod(executable, 0o500), so requireSelfCheck() and later activation cannot execute it. syncDirectory() does not persist the executable inode metadata, and stagedMatches() checks only bytes, causing retries to retain the broken version; sync the executable file after changing its mode.
// Read-only and executable for the owner: the bytes are final.
- await chmod(executable, 0o500);
+ await chmod(executable, 0o500);
+ const executableFile = await open(executable, "r");
+ try {
+ await executableFile.sync();
+ } finally {
+ await executableFile.close();
+ }🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/stage.ts around lines 65-66:
After a power loss, the published version can retain the artifact's original non-executable mode even though `stageCandidate` called `chmod(executable, 0o500)`, so `requireSelfCheck()` and later activation cannot execute it. `syncDirectory()` does not persist the executable inode metadata, and `stagedMatches()` checks only bytes, causing retries to retain the broken version; sync the executable file after changing its mode.
| if (!readiness) return "absent"; | ||
| if (readiness.artifactSha256 !== input.expectedSha256) | ||
| return "wrong-artifact"; | ||
| if (Date.parse(readiness.startedAt) < input.switchedAt.getTime()) |
There was a problem hiding this comment.
🟡 Medium update/readiness.ts:134
evaluateReadiness accepts a readiness record with startedAt === switchedAt, so a process started just before the selector swap in the same millisecond can be treated as ready. The strict < check must treat equality as stale-instance by using <=.
| if (Date.parse(readiness.startedAt) < input.switchedAt.getTime()) | |
| if (Date.parse(readiness.startedAt) <= input.switchedAt.getTime()) |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/readiness.ts around line 134:
`evaluateReadiness` accepts a readiness record with `startedAt === switchedAt`, so a process started just before the selector swap in the same millisecond can be treated as `ready`. The strict `<` check must treat equality as `stale-instance` by using `<=`.
| throw invalid("artifact"); | ||
| if (identity.minimumUpdaterContract > updaterContract) | ||
| throw invalid("updater-contract"); | ||
| if (!canRead(identity.schemas, expected.requiredSchemas)) |
There was a problem hiding this comment.
🟠 High update/signed-identity.ts:134
assertCandidateIdentity accepts the candidate based on the sidecar's claimed schemas, so with --folder omitted an artifact whose identity.json claims schema 2 can be staged even when its executable only supports schema 1. requireSelfCheck does not compare the executable's reported schemas, allowing activation of a release that cannot read the Folder state the running version writes. Make the self-check validate the executable's schemas against the signed identity (or otherwise enforce this compatibility check without --folder).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/signed-identity.ts around line 134:
`assertCandidateIdentity` accepts the candidate based on the sidecar's claimed `schemas`, so with `--folder` omitted an artifact whose `identity.json` claims schema 2 can be staged even when its executable only supports schema 1. `requireSelfCheck` does not compare the executable's reported schemas, allowing activation of a release that cannot read the Folder state the running version writes. Make the self-check validate the executable's schemas against the signed identity (or otherwise enforce this compatibility check without `--folder`).
| : {}), | ||
| ...(input.activationPolicy ? { policy: input.activationPolicy } : {}), | ||
| }) | ||
| ).kind === "in-progress" |
There was a problem hiding this comment.
🟡 Medium update/update.ts:229
When an abandoned activation record is read on a filesystem without flock, resumeActivation() converts UpdateFailure("lock-unsupported") into in-progress, so this check returns busy forever even though no worker owns the lock. The same mapping makes performRollback unrecoverable; preserve and return lock-unsupported instead of treating the lock failure as an active worker.
Also found in 1 other location(s)
src/update/lock.ts:74
acquireUpdateLocknow throwsUpdateFailure("lock-unsupported"), butresumeActivationcatches every activation-lock failure and returnsin-progress. Thus, if an interrupted activation record is later read from a filesystem withoutflock, every status/update/rollback treats the dead worker as live and never settles or rolls back the record instead of exposinglock-unsupported.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/update.ts around line 229:
When an abandoned activation record is read on a filesystem without `flock`, `resumeActivation()` converts `UpdateFailure("lock-unsupported")` into `in-progress`, so this check returns `busy` forever even though no worker owns the lock. The same mapping makes `performRollback` unrecoverable; preserve and return `lock-unsupported` instead of treating the lock failure as an active worker.
Also found in 1 other location(s):
- src/update/lock.ts:74 -- `acquireUpdateLock` now throws `UpdateFailure("lock-unsupported")`, but `resumeActivation` catches every activation-lock failure and returns `in-progress`. Thus, if an interrupted activation record is later read from a filesystem without `flock`, every status/update/rollback treats the dead worker as live and never settles or rolls back the record instead of exposing `lock-unsupported`.
…, resume at every start, restartable worker - trust: a supplied root seeds empty trust and is otherwise ignored unless it is the verified direct successor of the trusted root (the rule the compiled-in root needs); `trust-conflict` is gone. A damaged root.json is restored from the newest usable retained <N>.root.json before any supplied root, and promotion rewrites it. - previous.json is tolerant state: whatever occupies its place is cleared, and a write that still fails never blocks or unconfirms an activation. - Every CLI command converges an abandoned activation at start (one lstat without a record; one stderr line when it acted). self-check is exempt. - The systemd worker unit gets Restart=on-failure with a bounded start limit; a restarted worker continues ITS OWN record under the original deadline and never begins a settled operation again; an answering worker exits 0. - `--deadline-ms` / `--stability-ms` on update and rollback for qualification. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rt; the Launchpad settles an open activation itself - scripts/qualify-update-linux.ts builds a bundle (three real product executables, the signed loopback fixture server, the script); scripts/qualify-update-linux.sh runs ON a Linux Machine against its real systemd user manager, under its own HOME, and removes what it created. - systemd adapter: `reset-failed` precedes every restart — a crash-looped candidate leaves the unit in its start limit, where systemd refuses even a manual restart (reproduced on systemd 255). - watchActivation: a Launchpad that starts with a record open keeps looking until it is settled, which makes a reboot mid-activation converge without a command, in both directions. - docs/update.md states the reboot behaviour precisely and records the owner's decisions (supplied root, retained-chain repair, resume at every start, restartable worker, tolerant previous.json, Folder rule of self-check). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…temd 255) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| and the Launchpad service. Every `lazurio` start — each CLI command and the | ||
| Launchpad service itself — resumes the record, and a Launchpad that starts with |
There was a problem hiding this comment.
🟢 Low docs/update.md:142
The documentation falsely promises that every lazurio start resumes an open activation: self-check returns from convergeActivation() before calling resumeActivation(), leaving an abandoned activation record untouched. Exclude self-check from this claim or update its implementation to resume the record.
-Every `lazurio` start — each CLI command and the
-Launchpad service itself — resumes the record, and a Launchpad that starts with
+Every `lazurio` start except `self-check` — each other CLI command and the
+Launchpad service itself — resumes the record, and a Launchpad that starts with🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/update.md around lines 142-143:
The documentation falsely promises that every `lazurio` start resumes an open activation: `self-check` returns from `convergeActivation()` before calling `resumeActivation()`, leaving an abandoned activation record untouched. Exclude `self-check` from this claim or update its implementation to resume the record.
| . "$BUNDLE/bundle.env" # COMMIT, TARGET, TOOLCHAIN | ||
| : "${XDG_RUNTIME_DIR:=/run/user/$(id -u)}" | ||
| export XDG_RUNTIME_DIR | ||
| mkdir -p "$WORK/home" "$WORK/state" || exit 2 |
There was a problem hiding this comment.
🔴 Critical scripts/qualify-update-linux.sh:33
An existing WORK directory is accepted and recursively deleted by the cleanup trap, including all pre-existing contents; the same happens when the default path collides with a directory created earlier in the same second. Create the work-directory root with plain mkdir so the run aborts when it already exists.
-mkdir -p "$WORK/home" "$WORK/state" || exit 2
+mkdir -p "$(dirname "$WORK")" || exit 2
+mkdir "$WORK" || exit 2
+mkdir -p "$WORK/home" "$WORK/state" || exit 2🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qualify-update-linux.sh around line 33:
An existing `WORK` directory is accepted and recursively deleted by the `cleanup` trap, including all pre-existing contents; the same happens when the default path collides with a directory created earlier in the same second. Create the work-directory root with plain `mkdir` so the run aborts when it already exists.
| creates the first `bin/lazurio` (without a selector every update command | ||
| answers `not-installed`), launchd, the publisher, and every piece of native |
There was a problem hiding this comment.
🟢 Low docs/update.md:290
The documentation incorrectly says every update command returns not-installed without a selector: lazurio update status calls resumeActivation() and readObserved() without reading the selector, so it reports a rebuilt idle observation instead. Update the wording to distinguish status from selection-dependent commands.
| creates the first `bin/lazurio` (without a selector every update command | |
| answers `not-installed`), launchd, the publisher, and every piece of native | |
| creates the first `bin/lazurio` (without a selector, selection-dependent | |
| update commands answer `not-installed`; `update status` reports `idle`), |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/update.md around lines 290-291:
The documentation incorrectly says every update command returns `not-installed` without a selector: `lazurio update status` calls `resumeActivation()` and `readObserved()` without reading the selector, so it reports a rebuilt `idle` observation instead. Update the wording to distinguish `status` from selection-dependent commands.
| ExecStartPre=$WORK/unit-gate.sh pre | ||
| ExecStart=$L launchpad --folder $FOLDER | ||
| ExecStartPost=$WORK/unit-gate.sh post \$MAINPID |
There was a problem hiding this comment.
🟡 Medium scripts/qualify-update-linux.sh:135
A WORK, L, or FOLDER path containing whitespace makes the generated systemd unit fail to start, because ExecStartPre, ExecStart, and ExecStartPost parse each unquoted path as separate arguments (for example, /home/A User/... becomes /home/A). Quote these paths in the unit file so qualification works from valid user directories.
-ExecStartPre=$WORK/unit-gate.sh pre
-ExecStart=$L launchpad --folder $FOLDER
-ExecStartPost=$WORK/unit-gate.sh post \$MAINPID
+ExecStartPre="$WORK/unit-gate.sh" pre
+ExecStart="$L" launchpad --folder "$FOLDER"
+ExecStartPost="$WORK/unit-gate.sh" post \$MAINPID🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qualify-update-linux.sh around lines 135-137:
A `WORK`, `L`, or `FOLDER` path containing whitespace makes the generated systemd unit fail to start, because `ExecStartPre`, `ExecStart`, and `ExecStartPost` parse each unquoted path as separate arguments (for example, `/home/A User/...` becomes `/home/A`). Quote these paths in the unit file so qualification works from valid user directories.
| } | ||
| } | ||
|
|
||
| const positiveInteger = (value: string | undefined): number | undefined => |
There was a problem hiding this comment.
🟠 High update/cli.ts:195
positiveInteger accepts --deadline-ms 9999999999, allowing the activation worker to wait for roughly 115 days before rollback instead of enforcing the documented 120-second bound. Add validation that caps deadline and stability values at their supported defaults, and rejects a stability period greater than the deadline before constructing the worker policy.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/cli.ts around line 195:
`positiveInteger` accepts `--deadline-ms 9999999999`, allowing the activation worker to wait for roughly 115 days before rollback instead of enforcing the documented 120-second bound. Add validation that caps deadline and stability values at their supported defaults, and rejects a stability period greater than the deadline before constructing the worker policy.
| return "failed"; | ||
| } | ||
| } | ||
| const readiness = await service.launchpadReadiness(); |
There was a problem hiding this comment.
🟠 High update/activate.ts:285
candidateConfirmedNow can confirm a systemd-user record while bin/lazurio still selects record.previous, so a crash after rollback can permanently discard the unconfirmed record without activating the candidate. The readiness check only inspects the live Launchpad; verify that readSelector(base) === record.candidate before accepting readiness.
- const readiness = await service.launchpadReadiness();
+ if ((await readSelector(base)) !== record.candidate) return "failed";
+ const readiness = await service.launchpadReadiness();🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/activate.ts around line 285:
`candidateConfirmedNow` can confirm a `systemd-user` record while `bin/lazurio` still selects `record.previous`, so a crash after rollback can permanently discard the unconfirmed record without activating the candidate. The readiness check only inspects the live Launchpad; verify that `readSelector(base) === record.candidate` before accepting readiness.
| systemctl --user stop "$UNIT" 'lazurio-update-*' >/dev/null 2>&1 | ||
| rm -f "$UNIT_FILE" | ||
| systemctl --user daemon-reload >/dev/null 2>&1 | ||
| systemctl --user reset-failed >/dev/null 2>&1 |
There was a problem hiding this comment.
🟡 Medium scripts/qualify-update-linux.sh:83
systemctl --user reset-failed clears the failed state and restart counters for every failed user unit, so running this harness erases diagnostics for unrelated services. Pass $UNIT to limit cleanup to the qualification unit.
- systemctl --user reset-failed >/dev/null 2>&1
+ systemctl --user reset-failed "$UNIT" >/dev/null 2>&1🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qualify-update-linux.sh around line 83:
`systemctl --user reset-failed` clears the failed state and restart counters for every failed user unit, so running this harness erases diagnostics for unrelated services. Pass `$UNIT` to limit cleanup to the qualification unit.
| reason, | ||
| rolledBackTo: parseVersionName(record.previous)?.version ?? "", | ||
| }); | ||
| await rollBack(base, record, effects, error); |
There was a problem hiding this comment.
🟠 High update/activate.ts:715
When confirmation fails after record.previous has been removed, rollBack switches bin/lazurio to that missing version and then deletes the activation record, leaving the installation unusable. Recheck that record.previous still exists before rolling back; if it does not, retain the candidate and leave the record for the abandoned-activation recovery path to settle.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/activate.ts around line 715:
When confirmation fails after `record.previous` has been removed, `rollBack` switches `bin/lazurio` to that missing version and then deletes the activation record, leaving the installation unusable. Recheck that `record.previous` still exists before rolling back; if it does not, retain the candidate and leave the record for the abandoned-activation recovery path to settle.
| SERVICE=(--service systemd-user --unit "$UNIT") | ||
|
|
||
| cleanup() { | ||
| systemctl --user stop "$UNIT" 'lazurio-update-*' >/dev/null 2>&1 |
There was a problem hiding this comment.
🟠 High scripts/qualify-update-linux.sh:80
cleanup stops every lazurio-update-* unit in the user's manager, so a concurrent real update worker is terminated and its activation can be left interrupted. The same broad worker_unit lookup can inspect that unrelated worker instead of this run's worker; track the exact transient unit(s) created by each qualification update and use those identifiers for lookup and cleanup.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/qualify-update-linux.sh around line 80:
`cleanup` stops every `lazurio-update-*` unit in the user's manager, so a concurrent real update worker is terminated and its activation can be left interrupted. The same broad `worker_unit` lookup can inspect that unrelated worker instead of this run's worker; track the exact transient unit(s) created by each qualification update and use those identifiers for lookup and cleanup.
…ated failure-state floors Conflicts: docs/update.md keeps the revised contract text, the floor rule and this branch's supplied-root rule and deviations; trust.ts keeps floor capture with the supplied successor as a link of the verified chain; the fixture keeps both branches' additions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… version `lazurio install` refuses when a selector exists (`already-installed`), proves through an ordinary trust refresh that the bytes of `process.execPath` are a signed artifact whose signed identity is the identity compiled into them (`unverified-executable`, `identity-invalid`), and only then creates the base — every owned directory with an explicit 0700 whatever the umask — stages a verified copy of itself, and swaps in the selector. All or nothing: a refusal, no network, or a service manager that says no leaves no selector, no version, no unit of ours and no directory that was not there before. `--service systemd-user` (Linux) renders the Launchpad unit with a pure, unit-tested function (ExecStart is the selector, the base is explicit), refuses a unit of that name it did not write (`unit-conflict`), enables and starts it, and records service and Folder in update/config.json so that `lazurio update` and `update rollback` need no flag for them. `launchpad` accepts the explicit `--base` the unit carries. The qualification script now starts from `lazurio install --service`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… from the run that starts at lazurio install The qualification script ends with sync: a disposable Machine powered off hard right after it otherwise brings the removed unit and work directory back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Macroscope skipped reviewing this pull request. Per-review cost limit exceeded (workspace setting). This review would cost an estimated $12.17, which exceeds your per-review limit of $10.00. The top 3 files driving up this estimate:
Tip To get this pull request reviewed, you can:
|
Conflicts: check.ts keeps this branch's step continuation behind the new order refresh → capture → target lookup; trust.ts keeps the supplied-root rule and retained-chain repair inside the vector-driven promotion; errors.ts keeps both sets of codes; the fixture keeps both branches' options; docs/update.md keeps the revised contract, the floor vector section and this branch's deviations. The channel-document rollback test now expects `metadata-rollback`. Signature damage in tests is deterministic (a 1/256 flake: only the first byte was changed, which sometimes was already zero). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…age in tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Second slice of the product update mechanism (
docs/update.md), stacked on #5 (claude/DEV-6594-update-core). CLI only; no Launchpad UI.Why
#5 can tell that an update exists. This makes the click dependable:
lazurio updatechecks, downloads, verifies, stages, switches and confirms, and every failure on the way leaves the selected version working and the same command repeatable — includingkill -9of the worker at any point.What it does
src/update/download.ts). The signedartifacts/<sha256>/identity.jsonis fetched through the TUF client and checked before the large transfer: target equals the running binary's, version equals the channel's, digest/length equal the signed artifact, updater contract, and the release reads the Folder state schemas the running product writes. Then a resumable ranged GET intoupdate/scratch-*: one total deadline, bounded retries with backoff (only attempts without progress count), idle detection, free-space check first,ENOSPClater is the samedisk-full, length + SHA-256 against signed targets. Progress goes to a callback and toobserved.json(downloading+ percent).observed.jsonre-readsselectedfrom the selector;checking/downloadingsurvive only while some process holds the step lock,activatingonly while a record exists,readyonly while the staged version exists. A crashed download can no longer leavedownloadingstuck.stage.ts,self-check.ts). Newlazurio self-check [--json] [--folder <dir>]: prints the embedded identity and, for a named Folder, parses both state documents by plain reads (no lock — taking the Folder lock writes). The updater runs the candidate at its own path inside scratch with an empty environment and a timeout that holds even when a grandchild keeps the pipe open, compares the answer with the signed identity, and only then renames the directory intoversions/<version>+<sha16>/. Failure → typed error, nothing staged, scratch removed.activate.ts,activation-record.ts).bin/lazurio -> ../versions/<name>/lazurioswapped by atomic rename is the only selector.update/activation.jsonexists only during an activation:switching → confirming → removed(confirmed) or selector restored + removed (rolled back, error inobserved.json).resumeActivation()is called at the start oflazurio update …andlazurio launchpad; without a record it is one failedreadFileand writes nothing; with a record and no live worker it finishes or undoes deterministically; it never touches a live worker's switch.lazurio update apply-workeris started from the selected immutable executable — never the candidate, never whatever binary runs the command, never while a record exists — in its own session (or as a transientsystemd-run --user --wait --pipeunit, because a child of the Launchpad dies with its control group). Twoflocks: the step lock (released while waiting for the Launchpad) and a worker-liveness lock the kernel releases on death.service-control.ts,readiness.ts).none: confirmed by running the new executable'sself-checkthrough the selector and comparing identities.systemd-user:systemctl --user restart <unit>, then the readiness contract — a Launchpad that runs fromversions/writesupdate/launchpad-readiness.jsonwith the SHA-256 of its own executable (computed at start), pid and start time, and removes it at a clean exit; ready = that digest + started after the switch + pid alive + the same instance for the whole stability period. launchd is deliberately not implemented.lazurio update rollbackactivatesupdate/previous.jsonthrough the same record/worker/confirmation; refused (rollback-unavailable) when that version's signed identity cannot read the schemas the current product writes.lazurio update(concise progress on stderr, result or typed error code),--download-only,update rollback,self-check. Exit statuses are documented insrc/update/errors.ts.Owner decisions applied
channel-floors.json, its checks andchannel-rollbackare gone;sequencestays as ordering information. The rollback test now proves the TUF-level protection: replaying a once-valid older repository state, and substituting the older document alone, are both refused andtrust/does not move.root.jsonis restored from the newest usable retained<N>.root.json; only without one from a supplied root; else typedtrust-invalid.response-too-large(typed error class in the transport) andlock-unsupported(errno read through the existing FFI binding; onlyEINVAL/ENOLCK/ENOTSUPcount, everything else stays contention) have their own codes.What it deliberately does not change
src/distribution/(pilot installer) except its transport: one shared request policy, a ranged open, a typed too-large error. No Launchpad UI/poller, norestart-pending, no compiled-in root or default origins, no installer that creates the firstbin/lazurio(every update command answersnot-installedwithout a selector), no publisher.Verification
bun run checkgreen locally (macOS ARM64) at the code head: biome--error-on-warnings,tsc, 485 pass / 3 skip / 0 fail across 82 files, public guard, proof build, smoke. The suite went from ~152 s to ~189 s; the compiled journeys take ~31 s, so they stay insidebun testinstead of a separate script.tests/update-journey.test.tscompiles real executables A (1.0.0), B (1.1.0), C (1.2.0) once per process from a TEST-ONLY entry point (tests/fixtures/update-product.ts; faults are switched by a control file whose path is compiled in — product code has no test hook) and drivesbin/lazurioas a process against the signed loopback fixture:--versionthrough the selector says B, record gone, observation consistent, scratch pruned, previous retained; then rollback, then update again without a second download.--download-only, then activate; A→B→C retention keeps B and C only.self-check→ nothing staged; retry runs the whole gate again.activation-failed, retry works.kill -9of the caller (worker survives: it is outside the process group) and then of the worker — before the swap, after the swap, while confirming (both outcomes) → the nextlazurio update statusconverges, a second one changes nothing, the retry is the same command.lazurio updateprocesses; refused rollback; the Launchpad's readiness file written and withdrawn.tests/update-download.test.ts: resume after early EOF and after a mid-body reset, a server that ignoresRange, corrupt / too long / 404 / 503 / deadline, disk-full before and during (injected effects), every signed-identity refusal happens before any artifact request, staledownloadingcollapses.tests/update-activation.test.ts: systemd command construction, env allow-list, readiness evaluation, stability tracker, the worker with injected service/process/clock adapters (step lock free during restart, stale instance refused, crash loop never confirms, failing restart), every resume decision, a failing durable write at each point, self-check never writes to the Folder.Not dependable yet / risks
root.json+ bootstrap fallback, that one refresh runs without the old roles' rollback protection.self-checkreads Folder state without the Folder lock; a concurrent profile update can fail it (retryable).All deviations from the contract text are listed at the end of "Implemented so far" in
docs/update.md.Note
Add download, staging self-check, activation worker, and rollback to update system
channel-floors.json) and relies on TUF metadata validation and signed target hashes for rollback protection insteadChannelFloortype,assertNotRolledBackguard, andreadChannelFloors/advanceChannelFloorfrom channel.ts and trust.ts; any out-of-tree consumers depending on channel-floor state will break.acquireUpdateLockin lock.ts now returnslock-unsupportedimmediately on unsupported filesystems instead of polling until timeout.📊 Macroscope summarized c2b23ad. 24 files reviewed, 10 issues evaluated, 3 issues filtered, 6 comments posted
🗂️ Filtered Issues
docs/update.md — 1 comment posted, 3 evaluated, 2 filtered
not-installedwithout a selector is incorrect.update statuscallsresumeActivation()andreadObserved()without reading the selector, so an absent installation reports a normal rebuiltidlestatus;update --checklikewise performs its trust check before any selector check. [ Out of scope (post-validation triage) ]performUpdate()re-hashes a matching staged directory but callsrequireSelfCheck()only whenname !== selected; if an older executable is invoked directly while the selected version already matches the channel candidate, the retry skips the advertised self-check gate. [ Out of scope (post-validation triage) ]src/update/lock.ts — 0 comments posted, 1 evaluated, 1 filtered
acquireUpdateLocknow throwsUpdateFailure("lock-unsupported"), butresumeActivationcatches every activation-lock failure and returnsin-progress. Thus, if an interrupted activation record is later read from a filesystem withoutflock, every status/update/rollback treats the dead worker as live and never settles or rolls back the record instead of exposinglock-unsupported. [ Cross-file consolidated ]Follow-up on this branch: owner decisions + native Linux qualification
Decisions applied (commit
5d2b08c): a supplied root is never a conflict — it seeds empty trust and is otherwise ignored unless it is the verified direct successor of the trusted root (the rule the compiled-in root needs;trust-conflictis gone); damagedroot.jsonis restored from the retained chain;previous.jsoncan never block or unconfirm an activation; every CLI command resumes an abandoned activation at start (onelstatwithout a record, one stderr line when it acted;self-checkexempt); the worker unit hasRestart=on-failurewith a start limit of 3/120 s, a worker that answers exits 0, and a restarted worker adopts its own record under the original deadline. Self-check's Folder: this CLI has no default Folder location anywhere (every command takes an explicit--folder), so there was no rule to share; documented,--folderstays the way.Native qualification (commit
fe05ce9, evidencedocs/evidence/update-linux-arm64-2026-09-19.md):scripts/qualify-update-linux.ts build --target <linux-arm64|linux-x64> --out <dir>builds three REAL product executables + the signed fixture server;scripts/qualify-update-linux.sh <dir>runs ON the Machine under its ownHOMEand cleans up. One run, 76 s, 41 checks, 0 failed: Launchpad as a real systemd user service whoseExecStartis the selector (readiness = digest of 1.0.0,/proc/<pid>/exe= the versioned path); A→B with the worker as a transient unit in its own control group, running the previous executable, fresh stable instance (6.8 s); a refused candidate (0.9 s) and a crash-looping candidate (26.2 s, deadline 25 s) both rolled back with the Launchpad healthy again;kill -9of the worker while confirming → systemd restarts it, it finishes its own record, and the caller still receivesupdatedthrough--wait --pipe(10.4 s); rollback through the same path (5.8 s); reboot-equivalent → the Launchpad confirms itself with no command run (10.7 s); worker and Launchpad gone past the deadline → a plainlazurio --versionundoes it and restarts the service (13.6 s).What real systemd needed:
reset-failedbefore every restart (a crash-looped unit refuses even a manual restart — reproduced in isolation); the Launchpad now keeps looking while a record exists (watchActivation), otherwise a reboot would wait for a human command and then roll back a healthy version. The only thing that actually failed in front of us was the harness under Ubuntu'sumask 002— which is a requirement for the future installer (create its directories0700).Obvious next piece: a minimal installer/bootstrap. The harness had to lay out
versions/<name>/{lazurio,identity.json}and the selector by hand with the right modes; nothing in the product can create the first installation or the service unit.Follow-up 2: revised contract, floors,
lazurio install(head35cb0ab)278c449(revised contract + authenticated failure-state floors). Conflicts resolved:docs/update.mdkeeps the revised contract text, the floor rule, this branch's supplied-root rule and the deviations list;trust.tskeeps floor capture with a supplied successor as a link of the verified chain.lazurio install(src/update/install.ts): the running executable proves through an ordinary trust refresh that the SHA-256 ofprocess.execPathis a signed artifact and that the signed identity is the one compiled into it, then creates the base with explicit0700directories whatever the umask, stages a verified copy of itself through the same gate as any candidate, swaps in the selector.already-installedwhen anything sits at the selector; all or nothing (no selector, no version, no unit of ours, no empty directory it created).--service systemd-userrenders the unit with a pure unit-tested function (ExecStart = selector, explicit--base, systemd-safe quoting), refuses a unit it did not write (unit-conflict),daemon-reload+enable --now, and records service + Folder inupdate/config.jsonsolazurio updateneeds no--service.launchpadaccepts that--base. No port option exists in the Launchpad today, so none is taken.install→updateA→B →rollback.bun run check: 493 pass / 3 skip / 0 fail across 83 files.docs/evidence/update-linux-arm64-2026-09-19.md, source80728b5): starts fromlazurio-1.0.0 install --service systemd-userunderumask 002(modes verified700…/500 400, unit enabled, tampered copy refused with nothing created, second install refused); no later command passes--service. 48 checks, 0 failed, 78 s: install 1.4 s, A→B 6.8 s, refused candidate 1.2 s, crash-looping candidate 26.0 s,kill -9of the worker 10.3 s, rollback 5.8 s, reboot-equivalent 10.7 s,--versionundo 13.8 s.disable-lingerhad not reached the disk and were partly back at the next boot. Cleaned again, verified after a reboot, powered off from inside; the script now ends withsync.Follow-up 3: the floor vector merged (head
04bb02c)Merged #5 @
80770bc:trust/floors.json,metadata-rollback(exit 47), target lookup only after a refresh that completed without error and satisfied the vector — see #5 for the rules, the write-order argument and the old→new test mapping. On this branch the vector-driven promotion also carries the supplied-root rule (a verified supplied successor is a link of the chain and of the vector) and the retained-chain repair ofroot.json;lazurio updateandlazurio installrun their step only after the vector was satisfied. The channel-document rollback test now expectsmetadata-rollback.bun run checkat the merge: 501 pass / 3 skip / 0 fail across 84 files.🤖 Generated with Claude Code