Skip to content

Update: download, staging self-check, activation worker, rollback - #7

Closed
immakermatty wants to merge 10 commits into
claude/DEV-6594-update-corefrom
claude/DEV-6594-update-activate
Closed

immakermatty wants to merge 10 commits into
claude/DEV-6594-update-corefrom
claude/DEV-6594-update-activate

Conversation

@immakermatty

@immakermatty immakermatty commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

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 update checks, downloads, verifies, stages, switches and confirms, and every failure on the way leaves the selected version working and the same command repeatable — including kill -9 of the worker at any point.

What it does

  • Download (src/update/download.ts). The signed artifacts/<sha256>/identity.json is 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 into update/scratch-*: one total deadline, bounded retries with backoff (only attempts without progress count), idle detection, free-space check first, ENOSPC later is the same disk-full, length + SHA-256 against signed targets. Progress goes to a callback and to observed.json (downloading + percent).
  • Observation rebuild rule. Reading observed.json re-reads selected from the selector; checking/downloading survive only while some process holds the step lock, activating only while a record exists, ready only while the staged version exists. A crashed download can no longer leave downloading stuck.
  • Stage (stage.ts, self-check.ts). New lazurio 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 into versions/<version>+<sha16>/. Failure → typed error, nothing staged, scratch removed.
  • Activate (activate.ts, activation-record.ts). bin/lazurio -> ../versions/<name>/lazurio swapped by atomic rename is the only selector. update/activation.json exists only during an activation: switching → confirming → removed (confirmed) or selector restored + removed (rolled back, error in observed.json). resumeActivation() is called at the start of lazurio update … and lazurio launchpad; without a record it is one failed readFile and writes nothing; with a record and no live worker it finishes or undoes deterministically; it never touches a live worker's switch.
  • Worker. lazurio update apply-worker is 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 transient systemd-run --user --wait --pipe unit, because a child of the Launchpad dies with its control group). Two flocks: the step lock (released while waiting for the Launchpad) and a worker-liveness lock the kernel releases on death.
  • Service adapters (service-control.ts, readiness.ts). none: confirmed by running the new executable's self-check through the selector and comparing identities. systemd-user: systemctl --user restart <unit>, then the readiness contract — a Launchpad that runs from versions/ writes update/launchpad-readiness.json with 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.
  • Retention after confirm: active + previous + whatever a live Launchpad announces; other versions and all scratch are pruned.
  • Rollback. lazurio update rollback activates update/previous.json through the same record/worker/confirmation; refused (rollback-unavailable) when that version's signed identity cannot read the schemas the current product writes.
  • CLI. lazurio update (concise progress on stderr, result or typed error code), --download-only, update rollback, self-check. Exit statuses are documented in src/update/errors.ts.

Owner decisions applied

  • The first slice's deviations are accepted and listed in the doc.
  • Channel floors dropped. channel-floors.json, its checks and channel-rollback are gone; sequence stays 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 and trust/ does not move.
  • Damaged trust never wedges. A role file that is unreadable / not JSON / a directory counts as absent and is re-fetched under the still-valid root and replaced. A damaged root.json is restored from the newest usable retained <N>.root.json; only without one from a supplied root; else typed trust-invalid.
  • response-too-large (typed error class in the transport) and lock-unsupported (errno read through the existing FFI binding; only EINVAL/ENOLCK/ENOTSUP count, 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, no restart-pending, no compiled-in root or default origins, no installer that creates the first bin/lazurio (every update command answers not-installed without a selector), no publisher.

Verification

bun run check green 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 inside bun test instead of a separate script.

tests/update-journey.test.ts compiles 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 drives bin/lazurio as a process against the signed loopback fixture:

  • A→B: selector switched, --version through 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.
  • candidate that fails self-check → nothing staged; retry runs the whole gate again.
  • candidate that stages but fails confirmation → automatic rollback to A, activation-failed, retry works.
  • kill -9 of 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 next lazurio update status converges, a second one changes nothing, the retry is the same command.
  • two concurrent lazurio update processes; 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 ignores Range, corrupt / too long / 404 / 503 / deadline, disk-full before and during (injected effects), every signed-identity refusal happens before any artifact request, stale downloading collapses. 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

  • Natively qualified once, on one Ubuntu ARM64 VM with systemd 255 (see below). No Linux x64, no hosted canary, no real reboot — only its equivalent.
  • A worker killed after it settled and before it exited is restarted with no record and begins the same requested activation once more (bounded by the start limit of 3).
  • Readiness proves instance, digest, liveness and stability — not "Folder and protocol compatibility", and there is no gateway to distinguish from.
  • With a damaged root.json + bootstrap fallback, that one refresh runs without the old roles' rollback protection.
  • self-check reads Folder state without the Folder lock; a concurrent profile update can fail it (retryable).
  • PID reuse could make a dead Launchpad look alive; bounded by the digest + start-time checks.

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

  • Adds resumable artifact download with range requests, free-space checks, and progress reporting; staged candidates become visible only after passing a bounded self-check process that validates embedded identity and folder schemas
  • Adds activation workflow with injectable effects, service-control adapters (none and systemd-user), readiness tracking via durable files and process-liveness probes, atomic selector swapping, activation records, and rollback markers
  • Removes per-channel rollback floors (channel-floors.json) and relies on TUF metadata validation and signed target hashes for rollback protection instead
  • Adds Launchpad readiness announcements on startup and withdrawal on close, plus stable error codes and exit-status mapping for all update operations
  • Adds extensive test infrastructure including a compiled test-product helper, signed loopback repository fixture with fault injection, and end-to-end CLI journey tests
  • Risk: removes ChannelFloor type, assertNotRolledBack guard, and readChannelFloors/advanceChannelFloor from channel.ts and trust.ts; any out-of-tree consumers depending on channel-floor state will break. acquireUpdateLock in lock.ts now returns lock-unsupported immediately 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
  • line 283: The statement that every update command returns not-installed without a selector is incorrect. update status calls resumeActivation() and readObserved() without reading the selector, so an absent installation reports a normal rebuilt idle status; update --check likewise performs its trust check before any selector check. [ Out of scope (post-validation triage) ]
  • line 363: The claim that an already staged version always has its self-check run again is false. performUpdate() re-hashes a matching staged directory but calls requireSelfCheck() only when name !== 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
  • line 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. [ 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-conflict is gone); damaged root.json is restored from the retained chain; previous.json can never block or unconfirm an activation; every CLI command resumes an abandoned activation at start (one lstat without a record, one stderr line when it acted; self-check exempt); the worker unit has Restart=on-failure with 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, --folder stays the way.

Native qualification (commit fe05ce9, evidence docs/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 own HOME and cleans up. One run, 76 s, 41 checks, 0 failed: Launchpad as a real systemd user service whose ExecStart is 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 -9 of the worker while confirming → systemd restarts it, it finishes its own record, and the caller still receives updated through --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 plain lazurio --version undoes it and restarts the service (13.6 s).

What real systemd needed: reset-failed before 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's umask 002 — which is a requirement for the future installer (create its directories 0700).

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 (head 35cb0ab)

  • Merged Update core: embedded identity, durable trust promotion, check and observed state #5 @ 278c449 (revised contract + authenticated failure-state floors). Conflicts resolved: docs/update.md keeps the revised contract text, the floor rule, this branch's supplied-root rule and the deviations list; trust.ts keeps 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 of process.execPath is a signed artifact and that the signed identity is the one compiled into it, then creates the base with explicit 0700 directories whatever the umask, stages a verified copy of itself through the same gate as any candidate, swaps in the selector. already-installed when anything sits at the selector; all or nothing (no selector, no version, no unit of ours, no empty directory it created). --service systemd-user renders 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 in update/config.json so lazurio update needs no --service. launchpad accepts that --base. No port option exists in the Launchpad today, so none is taken.
  • Tests: layout and modes under umask 002 and 077; unsigned bytes / foreign identity / failing self-check / no network install nothing; already-installed; unit rendering and quoting; unit-conflict; a refusing service manager undoes everything; compiled journey fresh HOME → tampered download refused → install → update A→B → rollback. bun run check: 493 pass / 3 skip / 0 fail across 83 files.
  • Native Linux ARM64, second run (docs/evidence/update-linux-arm64-2026-09-19.md, source 80728b5): starts from lazurio-1.0.0 install --service systemd-user under umask 002 (modes verified 700…/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 -9 of the worker 10.3 s, rollback 5.8 s, reboot-equivalent 10.7 s, --version undo 13.8 s.
  • Operator lesson: the VM had been stopped hard right after the first qualification; removals and disable-linger had 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 with sync.

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 of root.json; lazurio update and lazurio install run their step only after the vector was satisfied. The channel-document rollback test now expects metadata-rollback. bun run check at the merge: 501 pass / 3 skip / 0 fail across 84 files.

🤖 Generated with Claude Code

immakermatty and others added 2 commits September 19, 2026 03:26
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>
Comment thread src/update/activate.ts Outdated
policy?: Partial<ActivationPolicy>;
}): Promise<ResumeResult> {
const { base } = input;
if ((await readActivationRecord(base)).kind === "absent")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/update/stage.ts
Comment on lines +65 to +66
// Read-only and executable for the owner: the bytes are final.
await chmod(executable, 0o500);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment thread src/update/readiness.ts
if (!readiness) return "absent";
if (readiness.artifactSha256 !== input.expectedSha256)
return "wrong-artifact";
if (Date.parse(readiness.startedAt) < input.switchedAt.getTime())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 <=.

Suggested change
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 `<=`.

Comment thread docs/update.md Resolved
throw invalid("artifact");
if (identity.minimumUpdaterContract > updaterContract)
throw invalid("updater-contract");
if (!canRead(identity.schemas, expected.requiredSchemas))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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`).

Comment thread src/update/update.ts
: {}),
...(input.activationPolicy ? { policy: input.activationPolicy } : {}),
})
).kind === "in-progress"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

acquireUpdateLock now throws UpdateFailure(&#34;lock-unsupported&#34;), 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.

🚀 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`.

immakermatty and others added 3 commits September 19, 2026 03:36
…, 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>
Comment thread docs/update.md
Comment on lines +142 to +143
and the Launchpad service. Every `lazurio` start — each CLI command and the
Launchpad service itself — resumes the record, and a Launchpad that starts with

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Comment thread docs/update.md Outdated
Comment on lines +290 to +291
creates the first `bin/lazurio` (without a selector every update command
answers `not-installed`), launchd, the publisher, and every piece of native

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.

Suggested change
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.

Comment on lines +135 to +137
ExecStartPre=$WORK/unit-gate.sh pre
ExecStart=$L launchpad --folder $FOLDER
ExecStartPost=$WORK/unit-gate.sh post \$MAINPID

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/update/cli.ts
}
}

const positiveInteger = (value: string | undefined): number | undefined =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment thread src/update/activate.ts
return "failed";
}
}
const readiness = await service.launchpadReadiness();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/update/activate.ts
reason,
rolledBackTo: parseVersionName(record.previous)?.version ?? "",
});
await rollBack(base, record, effects, error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment thread scripts/qualify-update-linux.sh Outdated
SERVICE=(--service systemd-user --unit "$UNIT")

cleanup() {
systemctl --user stop "$UNIT" 'lazurio-update-*' >/dev/null 2>&1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

immakermatty and others added 2 commits September 19, 2026 04:08
…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>
@macroscopeapp

macroscopeapp Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

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:

File Diff Size Estimate
src/update/activate.ts 25.70KB $1.28
docs/update.md 24.84KB $1.24
src/update/cli.ts 23.39KB $1.17

Tip

To get this pull request reviewed, you can:

  1. Comment @macroscope-app on this PR to request a manual review (monthly spend limits still apply).
  2. Exclude the file(s) above from review by adding a pattern to your .macroscope/ignore.md — note that creating this file replaces Macroscope's built-in default ignores rather than extending them.
  3. Raise your cost limit in your workspace billing settings.

Turn off this reminder going forward

immakermatty and others added 2 commits September 19, 2026 04:57
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>
@immakermatty

Copy link
Copy Markdown
Contributor Author

Superseded by #11, which implements the simplified contract merged in #10 (decision F13: GitHub Releases + Sigstore attestation, one activation path). The TUF-based stack in this PR is not continued; nothing from it should be ported except what #11 already carries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant