Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Update transactions and durable requests

Tento článek zatím není přeložen. Je dostupný v angličtině.

Developer reference

Update transactions and durable requests

Owning code:

  • src/update/transaction.ts: runUpdateTransaction (line 141), the single executable transaction.
  • src/update/request.ts: the durable queue in <updateDir>/requests.json. readQueue (line 39), enqueuePluginOp (line 58), enqueuePluginOpsLocked (line 71) and enqueueCoreRun (line 84).
  • src/update/pluginMutation.ts: withPluginMutation (line 14), the lifecycle fence.
  • src/update/report.ts: writeReport (line 159), retireUpdateRequests (line 225) and acknowledgeUpdateRequest (line 246).
  • src/update/journal.ts: the journal, which accepts schema 2 only (line 155).
  • src/plugins/manifest.ts: validateManifestFolder (line 53). src/plugins/installReceipt.ts: the receipt shape and its strict parser.
  • src/cli/update.ts: candidate selection and queue draining. src/cli/updateLock.ts owns acquireUpdateLock and the lock modes shared with standalone client startup.
  • src/cli/updateTransactionIO.ts: defaultTransactionIO(ctx): Promise<TransactionIO> (line 229), which wires the real core and marketplace halves and owns the PostgreSQL pool used by configuration operations.
  • src/cli/rootUpdateStep.ts: runInstallReleaseStep (line 107) and refreshInstalledHost (line 84).
  • src/shared/updateBudgets.ts: the phase deadlines and the TimeoutStartSec value.
  • src/cli/install/systemdUnits.ts: the coordinator .path and .service units. The queue path is at line 270 and TimeoutStartSec at lines 179 and 294.
  • src/api/routes/system.ts: GET /system/update (line 152), POST /system/update (line 164) and the acknowledgement route (line 175).
  • src/shared/version.ts: isNewer (line 3).
  • src/licence/releases.ts and src/licence/artifact.ts: the licensed release feed, the pinned descriptor lookup, the root step's request parser and the verified artifact download.

The update coordinator awaits defaultTransactionIO(ctx) before planning or recovery. Constructing the handle does not require a database URL or connection: empty recovery uses instance files only. The handle opens one shared worker pool directly through elowen-db.createCoreDatabase on its first configuration or plugin-schema operation, with its size read from the instance's harness.json databasePoolRunner. The protected ELOWEN_DATABASE_URL selects the database; the independent instance root locates the durable plugin-secret key and other instance files. Worker construction disables authoritative preparation and key initialization, and never runs schema migrations. Configuration reads and writes await the typed config repository. The coordinator awaits the handle's close() when replacing an unused planning or recovery handle, after transaction completion or failure, and before releasing its update lock; repeated closes share the same completion. The timer's opt-in read and the offline usage rebuild each own and await closure of their separate short-lived worker pool.

Coordinator publication uses writeReport(updateDir, report, transaction) with a required source containing ops and coreRequest. The CLI's local writeTerminalReport maps normal completion and automatic or manual recovery through this same writer, preserving finishedAt, checked, optional notes and per-request verdicts. Preparations and retirements use the private writer without a journal; absence of transaction provenance there adds no journal-derived verdicts. Reports remain status hints, retain at most 100 operations for 24 hours and carry no installation or recovery authority.

retireUpdateRequests(updateDir, requests, detail) requires the caller's cancellation reason. Replacement producers retain the replacement detail; dropQueuedOpsLocked identifies a plugin disabled or removed before execution. Callers hold requests.lock, and report.lock protects the durable verdict publication before queue removal. An empty request set writes nothing. The durable superseded state and owner-scoped acknowledgement contract do not change. Plugin disable and uninstall are real consumers.

Installer package paths and update coordinator provisioning reuse shared/installResolution.installedPackagePath. Installed and retained modules resolve to the stable package link; a source checkout returns null and these consumers retain their source package root. An explicitly injected CLI path is preserved and participates in source-checkout detection. No new install-kind resolver or filesystem lookup is introduced.

src/cli/install/spec.ts::assertInstallSpecLoadable validates the final InstallSpec for both the interactive installer and planAndGate, including unattended adoption. Through Runner.exec, one read-only test -f <prefix>/bin/elowen -a -x <prefix>/bin/elowen accepts executable files and links to them, and refuses missing paths, broken links, directories and files without execute permission. Failure names the path and directs the operator to --package-prefix before any unit or host file is written. The prefix remains the explicit flag or the caller-resolved npm prefix -g; no alternate prefix is inferred. After the executable check, the same validation checks the optional operator environment file against the instance pins.

src/cli/install/spec.ts pins the explicit instanceRoot in the service environment and writes the protected database URL to /etc/elowen/instance.env. convergeUpdateCoordinator applies inline unit environment followed by EnvironmentFile values, requires a canonical absolute ELOWEN_INSTANCE_ROOT, and checks it against the install record. It never derives filesystem authority from the database URL or root's process HOME. Literal-path and service-user identity checks remain in the coordinator. Privileged refresh is a real consumer.

refreshPrivilegedWorker returns Promise and reports its effects through the supplied log callback. runPrivileged awaits it and returns the command exit code. File changes retain the drained cutover, unit-only changes retain one worker restart, and unchanged files retain the existing current-state log. The real withUpdateTimer switch remains available for candidate installation; only RefreshAfterUpdateIO.converge removes its constant timer argument and calls the real converger with true.

Database host-policy reconciliation follows the proven-release boundary. Candidate privileged refresh --keep-update-timer prepares read-only; after package proof and commit, the root step re-executes the installed CLI and applies policy. A no-update run reaches the same owner through privileged refresh --database-policy-only, using the existing pinned sudo root-update channel with fixed {op:'policy'} stdin. It accepts no paths, SQL or resource profile. An unproven installed candidate refuses this operation; empty queued runs remain side-effect free. The root step forwards the installed refresh child's stdout and stderr live, including restart-required and operator-required outcomes. The coordinator's defaultUpdateExec retains the last 262,144 characters of each stream, keeps stderr live, and includes up to 8,192 characters of failure diagnostics in the thrown error on nonzero exit or signal. Each diagnostic line is bounded to its last 8,192 characters, then sanitized with the existing redactProblemText before entering the journal, report and request verdict; arguments and stdin are not included. Successful structured stdout is unchanged.

The root-owned installation record is the only policy authority. A kernel lock serializes application; record publication revalidates database identity and preserves unrelated fields. Desired, written and effective profile identities remain independent, so restart-required is not fully effective. A local record without database.policy, like an external/shared host, returns operator-required without database commands, record writes or actual GUC-diff inspection. Privileged refresh writes this warning to stderr so the coordinator's journal retains it, but does not roll back an otherwise valid core release. Automatic database tuning still requires explicit adoption of approved resources, decisions and managed-file identities; no budget is inferred from current hardware or manual limits. Malformed policies and unrecorded file drift in an already adopted policy remain failures.

Policy failure after core commit returns nonzero and explicitly retains the committed package. Recovery finishes forward; it neither reverses schema migrations nor reports host convergence as successful.

Standalone client packages

src/cli/clientUpdate.ts materializes a prebuilt, signed and hash/size-verified archive through the same stageCoreSwap, apply/commit/restore and inventory owners. The client keeps an explicit prefix install.json through the existing install-info module, and serializes bootstrap, recovery and startup checks with updateLock.ts. The common kernel lock uses the inherited descriptor on Linux and macOS; the latter ships a native kernel-lock helper beside its bundled Node. No daemon lifecycle participates in a client update. The new package proves --version, and a successful swap re-executes the stable client entry before admitting a turn. First bootstrap creates both command links before commit. Recovery of its from: null intent removes those owned links and its install record, so the original archive can retry cleanly. See the client contract for the authenticated feed and build layout.

For full server packages, updateTransactionIO starts the currently selected installed release through its stable public bin.js up entry. Rollback invokes the restored release itself rather than importing an internal module from another version.

Blocked preparation and rollback

The journal and report share terminal blocked for preparation, planning or target-plugin preflight failures before services stop. No candidate was activated and the installed version is unchanged. isTerminal lets the next request proceed and recovery reconstructs missing request verdicts without restoring packages. The CLI publishes blocked in the system report and core request verdict; individual plugin requests retain their concrete failure outcome. Settings and the operation dock display the localized blocked label as a finished failure. ProblemService reports update.blocked once per journal transaction, with the original reason and an explicit unchanged-version description. Actual rollback continues to report update.rolled_back; both reach the publisher when reporting is enabled.

Single executable update transaction

Core releases and every publisher-plugin install, update and repair use runUpdateTransaction in src/update/transaction.ts. The coordinator plans from install receipts and unchanged trees, stages with the target parser, preflights the effective baseline plus replacements, stops services, records and applies swaps, starts the candidate, proves the whole set, then commits or restores and proves the old set. Every run plans its explicit requests plus every enabled publisher plugin with a newer version, so one request moves the whole stale set: after a shared-API bump the preflight refuses any set in which a loaded plugin still needs the old API, and one-plugin runs would each roll back. The preflight reads, like the loader, only plugins the target loads (the enabled set plus enabling requests) and every staged replacement; a disabled plugin is never parsed and advances when it is enabled. Enabled customer plugins participate in compatibility preflight but are never changed by this path.

Restoring the old set restores packages and plugin trees, never the database. Core schema migrations (packages/db/migrations) are forward-only: the candidate applies them when it boots, and the restored core's migration runner refuses a ledger that records versions it does not ship. A rollback after a candidate applied a new migration therefore leaves the daemon unable to start, and the operator repairs the instance by hand (reinstall the candidate release or fix forward). There is deliberately no compatibility path for older releases. src/plugins/manifest.ts owns validateManifestFolder, the common post-parse identity and lexical entry-containment check used by loader, marketplace staging and preflight. Callers pass the logical folder name independently of the physical staging path and receive the resolved entry path. Preflight still parses with the target core and verifies realpath containment and regular files for plugin and service entries. Marketplace still rejects staging symlinks and bounds its tree size; loader still imports the validated entry. These filesystem and execution boundaries remain distinct. A manifest the running parser rejects does not erase a publisher plugin's installed identity: the receipt supplies its installed version and the listings expose degraded with the load failure reason.

The previous state is observed before each swap. from: null and backup: null mean the folder was actually absent, not that the operation was called an install or repair. An existing unloadable folder keeps a backup. expectLoad records enablement after the transaction: enabled replacements must load alongside the baseline; disabled replacements are proven by their tree inventory. Before changing configuration in the stopped window, the journal records the enablement delta. The awaited repositories.config.setPluginEnabled changes only plugins.enabled under its PostgreSQL settings-row lock and revision. Recovery reverts that delta idempotently without overwriting unrelated settings or a later operator change.

The stop window includes sockets and independent services executing from affected plugin folders, not only the daemon and web. They must be stopped before either forward swaps or rollback. Service names come from each installed raw manifest, so an old API rejection cannot omit an old unit. A missing plugin folder is observed absence; a malformed existing manifest is a failure. Unit load state is read through the narrowly granted service command: only not-found skips an unprovisioned unit. The daemon's existing independent-service lifecycle provisions and starts new units; the coordinator never invents a second readiness policy. The boot report is completed after pluginServices.startAll settles, with pluginServices results and servicesSettled; the proof requires successful convergence for swapped independent services. If the candidate cannot stop, no backup is renamed underneath it: needsOperator preserves the set. During candidate boot a validated swap intent in the active journal establishes publisher ownership, so a new plugin can register before it has its final receipt. Receipts are published only after proof. Receipt publication failure retains committing, swap intents and backups, never a terminal success. Recovery republishes every receipt idempotently before admitting another transaction; candidate boot continues to recognize publisher ownership from those active intents. src/plugins/installReceipt.ts owns the receipt shape and its strict parser. The receipt store and journal both import this contract directly; journal validation never imports a store that reads the journal. Marketplace candidates and their fixtures use the same contract, without a second parser.

Durable update requests

src/update/request.ts is the core producer/consumer seam, not another installer or recovery store. It stores normalized operations in <updateDir>/requests.json: at most one install, update or repair per plugin, with enablement, an optional approved version/checksum pin, request id, time and account. An optional core request selects the whole-instance update while retaining explicit plugin operations. The prepared journal stores it as coreRequest, alongside ops; the timer explicitly requests a core run, whereas a marketplace start is a plugin-only run that still takes every eligible enabled plugin update along with its queued requests. Minimal use for a boot repair is:

await enqueuePluginOp(updateDir, { name, op: 'repair', enable: true, pin: null, by: null });

enqueueCoreRun(updateDir, by, uiInitiated?) queues a whole-instance run. POST /system/update awaits it before answering 202 { requestId, queued: true }, so the returned id already exists in requests.json. Marketplace single-plugin routes likewise await enqueuePluginOp; batch updates await withPluginMutation and call the synchronous enqueuePluginOpsLocked while holding the request lock. Only the coordinator executes the transaction. The acknowledgement route awaits withRequestLock so an active owned UI request answers HTTP 409 before a response is serialized.

An explicit update whose unchanged installed receipt already has the offered version is satisfied as applied, with no swap or restart. A pin additionally requires the installed artifact checksum to match; a changed registry pin is superseded, and a downgrade remains cannot-stage and failed. Satisfied requests are not deferral warnings. Existing bad reports are replaced only by a subsequent real coordinator run, not by a migration or a presentation override.

enqueuePluginOpsLocked accepts several existing operation shapes under requests.lock, validates them all and publishes exactly one durable queue write. enqueuePluginOp uses this same writer. Settings Plugins calls admin POST /plugins/marketplace/update with optional acknowledgeGrants keyed by plugin name. The route prepares every catalog updateAvailable entry, including disabled ones, gates all consent, controls and licences before publishing, and preserves enablement. Its final withPluginMutation check shares the claim barrier and returns the existing 409 with request id when a transaction owns a member. Nothing offered adds no operations. The 202 returns queued: true and the accepted plugins operations with individual request ids; it neither updates core nor adds a second coordinator, poll or restart. One queue claim owns the entire batch and its one stop/start.

The batch route is POST /plugins/marketplace/update (src/api/routes/plugins/marketplace.ts:87-113). The single-plugin routes are POST /plugins/marketplace/:name/install and POST /plugins/marketplace/:name/update (lines 165-166). A single-plugin route answers 202 with its requestId and queued: true (lines 159 and 185).

Marketplace routes first gate the candidate's consent, controls and entitlement, then enqueue the pinned operation. The enabled elowen-update-coordinator.path has PathExists=<updateDir>/requests.json and activates elowen-update-coordinator.service, running elowen update --queued. This explicit mode waits up to ten minutes for the existing kernel update lock through acquireKernelLock's timeoutMs. Phase deadlines come from src/shared/updateBudgets.ts: ten minutes for ownership, sixteen minutes for privileged-worker drain, and three minutes for each boot proof. Candidate and rollback can each need a drain and proof, already exceeding the former twenty-minute aggregate deadline; planning, host-lock waits, refresh and additional queued runs add further time. Both update services therefore render TimeoutStartSec=infinity: there is no finite bound on the number of transactions drained by one activation, and PID 1 must not kill a swap. Phase deadlines still fail normally. After acquiring the lock, unattended modes read the installed core inventory from the stable package prefix before recovery, selection or request claim. If its version differs from the loaded CLI, they return success with checked: false, preserving the queue, journal and report. A stale queued process hands the unchanged queue to the current CLI through the path watcher; a stale timer leaves any pending force-update signal unacknowledged for the next tick. Source checkouts have no installed core identity. A current coordinator recovers an interrupted journal, then drains only queued requests. An empty queue returns success with Nothing queued. without a feed read, core enqueue or replacement report. Real failures, rollback and operator attention produce a non-zero exit. The timer uses automatic mode: it waits on the same lock and always requests a full check; an operator retains fail-fast locking and the existing selection behavior. PID 1 rechecks path presence immediately after the oneshot terminates, even after a successful stale exit, including a request enqueued after its final empty read. This is the documented systemd.path behavior; service start rate limits still apply. Empty queues remove the file and fsync its directory under requests.lock. There is no daemon-side service-start trigger or sudoers grant for starting the coordinator. The System button, timer and CLI share that coordinator; a one-plugin marketplace update also advances every other enabled plugin with a newer version, in the same run and restart. Manual requests work with automatic updates off. Boot repair, after the registry is built, queues enabled receipted plugins that are missing or unloaded. It reports customer failures without repairing them, uses the last-good cache offline, and does not repeat a version whose activation already failed proof. Operator repair is an explicit retry through POST /plugins/marketplace/:name/repair, which answers 409 plugin does not need repair unless the plugin is a repair candidate, degraded, or missing its receipt (src/api/routes/plugins/marketplace.ts:168-178). Boot repair calls enqueuePluginOp at src/daemon/bootstrap.ts:638, and it is skipped while a journal is active (src/daemon/bootstrap.ts:622-623).

CLI candidate selection, licence renewal, request draining and recovery entrypoints stay in src/cli/update.ts. Its defaultTransactionIO(ctx) collaborator in src/cli/updateTransactionIO.ts wires the real core and marketplace halves, preflight, boot report and supervisor. It owns the narrower TransactionIODeps port, which UpdateDeps extends; the wiring has no import back to the coordinator. The marketplace opens the daemon's DB with migrate: false, and its handle closes once after proof, rollback or an unused planning/recovery pass. Initial discovery and preflight share one bundled plugin path; preparing a core candidate changes only preflight's staged path and verified manifest parser. The production update command and the offline API-transition harness use this same wiring. The offline harness packages a production manifest and matching root lock under the release archive's package/ prefix, runs real tar extraction and toolchain probing, and substitutes only the pinned npm ci command with fixture dependency links. Candidate boot and exact rollback remain real.

src/cli/rootUpdateStep.ts owns runInstallReleaseStep, the structured outcome parser, installed CLI resolution, core-record selection and refreshInstalledHost(exec, args). The CLI dispatches the pinned root command directly to this module before environment or credential handling. The module does not open the instance DB or import the coordinator; stage, install, refresh, commit, restore and clean still travel solely through the existing stdin protocol. Both the coordinator's final refresh and the transaction's root convergence re-execute the installed release through the shared refresh helper. A service-user writable prefix keeps its instance record; retained root recovery uses the trusted record. No installation in a source checkout means core swaps are unavailable, while queued plugin work retains its existing behavior. The split adds no worker, polling, hook or alternate update path.

The short requests.lock is separate from the update lock: a producer must never wait on the updater that is about to stop its daemon. Later actions supersede queued operations for the same name; disable or uninstall removes them. With no queue file, reads return an empty queue and do no work. The coordinator takes requests and writes their operations into a new prepared journal under the request lock before clearing the queue. A crash before the write retains the queue; recovery after it uses the journal and requeues prepared operations. It drains newly arrived requests in further runs; the path watcher also activates leftover queues at boot. An unreadable queue is a failure, not a success-shaped empty queue.

The queue is bounded work, never a record of installed bytes. The file is capped at 256 KiB (src/update/request.ts:30). An oversized file, a file that fails the queue schema, or a plugin named twice throws instead of reading as an empty queue (src/update/request.ts:42-44). Pins prevent applying a catalog version the operator did not approve: a changed pin ends superseded. Consent and the renewed licence are rechecked when staging; refusals become per-request outcomes. Requests are claimed before licence renewal so a lost licence also yields terminal not-licensed, rather than an indefinitely queued id. An active non-terminal journal, including rollback and operator recovery, suppresses automatic boot repair and fences adoption and uninstall. withPluginMutation in src/update/pluginMutation.ts fences enable, disable and uninstall, plus actual plugins.enabled differences in PUT /config, under that same request lock. Frozen names include request ops, swaps, the loaded proof baseline and enablement deltas. Refusals are HTTP 409 { error: "update in progress", requestId }, correlated to the plugin op/swap, core request, or run id. The server maps UpdateInProgressError to that body (src/api/server.ts:74). The synchronous callback performs the write and queued cancellation without awaiting or nesting request locks. Uninstall checks before external cleanup and again at its final marker/config write. Unrelated names and terminal journals do not block these lifecycle writes. Adoption still fences the whole active transaction. A real consumer is the Plugins disable toggle, PATCH /plugins/:name (src/api/routes/plugins/index.ts:403-425). Uninstall is fenced inside src/plugins/marketplace.ts:385, and the PUT /config path is fenced at src/api/routes/config.ts:183. Boot sweep is only bounded housekeeping of parked deletions, debris and host dependency links, with neither an active journal nor a running updater.

There is no daemon-side executable swap, candidate-boot appended swap intent, pending-<name>, pendingRecorder or settleInstalls path. Settle old pending work with the previous release before cutover; there is no compatibility migration. The journal alone owns crash recovery and accepts only schema 2 (src/update/journal.ts:155). A schema-1 journal fails with its filename and instructions to finish with the older updater or archive it only after proven recovery. Customer transition is operator-managed, not an automatic old-coordinator update. Admin GET /system/update (src/api/routes/system.ts:152-162) exposes the queue, journal summary and last report, including request ids and per-plugin applied, rollback, consent, licence, superseded, failed and skipped outcomes. Shared update record types, including SkippedCandidate, belong to src/shared/wireContract.ts; selection, reporting and CLI consumers import them directly. Marketplace owns candidate selection, not the report contract, so request retirement can publish a report without a dependency back into marketplace or the journal.

Runtime UPDATE_STATES, UPDATE_REPORT_STATES, PLUGIN_OUTCOMES and UPDATE_REQUEST_STATES belong to src/update/states.ts. Journal validation, report validation and the request-verdict TypeBox schema consume those lists. src/shared/wireContract.ts exports only the corresponding literal-union types; it has no runtime state lists. satisfies rejects extra values, and the erased CompleteStateList type checks complete coverage of each union. The request-state list deduplicates report outcomes, plugin outcomes and downloading. Accepted values and their order remain unchanged; importing the module performs no I/O or update operation.

The report is a status hint, not installation authority. Its run summary remains the latest run; its optional operations list retains up to 100 request verdicts for 24 hours across unrelated runs. Every consumed core and plugin request id is listed in that run. writeReport(updateDir, report, transaction) copies the request's owner, UI provenance and request timestamp from the claimed journal before another transaction replaces it, and combines them with the matching per-request verdict. A missing plugin verdict fails publication instead of attributing the whole run's success to that plugin. The journal's optional requestOutcomes carries selection verdicts for requests without swaps, so interrupted commit recovery preserves exact consent, licensing and already-current decisions. Proven commits and rollbacks record finishedAt before publication. If a crash leaves either terminal journal without matching report request ids, recovery reconstructs its exact swap and non-swap verdicts before another run can replace it. The restored report uses that completion time; a missing time is checkpointed once during reconstruction. Already-reported requests return no recovery outcome, preserving their timestamps and acknowledgements across later coordinator starts. Stop/start failures before the first swap also publish failed results for each selected plugin request, including the needsOperator branch. Recovery also reports requests interrupted before a swap as failed for that run; requeued work takes precedence in the dock. Queue normalization and disable/uninstall publish an explicit superseded verdict before removing a request.

Settings core, single-plugin and batch-plugin updates, installs and repairs send uiInitiated: true (web/lib/elowenClient.ts:546, 689, 696, 698, 699); the daemon records it only for a full human login through fullHumanLogin, never a turn credential, personal API token, setup request or unmarked CLI request. Absent provenance means the request does not dock. The existing queue and journal schemas carry this optional positive UI claim without another store. The browser's pure updateDockOperations(status, userId) projects only that person's marked requests, using update:<requestId> as the dock id and null progress because the coordinator supplies no percentage. It follows the shared admin-only useSystemUpdateStatus query through reload and daemon reconnect; the update dialog (web/components/ui/UpdateOperationProgressDialog.tsx) composes the shared Modal, Button and Progress and Settings state translations. The query is useSystemUpdateStatus (web/lib/queries.ts:384), and the dock projection is updateDockOperations (web/lib/operationDockUpdates.ts:27).

Plugin install and single-update preparation publishes a downloading operation in the same report before prepareCandidate awaits the registry package. The record is feedback only: no queue is published until the exact artifact passes consent and dependency checks. enqueuePluginOp accepts that preparation's {requestId, requestedAt}, preserves the approved artifact pin, publishes the queue durably, then removes the preparation record under the same producer barrier. Queue and journal carry progress from that point, and terminal report projection copies op from the plugin request so install, update and repair keep their identity. Download failures and consent/dependency refusals retain the exact request outcome and can be acknowledged. The repair route has its own strict uiInitiated body and existing receipt/eligibility checks; its package preparation already belongs to the coordinator and stays visible through queued/prepared states. Daemon bootstrap runs recoverPluginPreparations once: orphaned downloads become failed with an interruption reason; matching queue or journal ids remove stale preparation feedback and leave coordinator recovery authoritative. It never runs in attached runners. All reads remain bounded by the existing report limits.

A content-free update SSE event carries only the initiating account id, is delivered only to that current administrator, and invalidates only QUERY_KEYS.systemUpdate. Marketplace publication emits it before awaiting the download and after queue acceptance or refusal; the shell's existing query then polls active downloads once per second as well as queued/coordinator work. No event payload duplicates an operation row, and event recording skips this transient nudge even when a plugin supplies a row resolver.

POST /system/update/operations/ack { requestId } acknowledges only the authenticated human's own marked, settled request. Unknown and foreign ids return 404, active requests return 409, and repeated acknowledgement returns the original timestamp. Acknowledgement and coordinator publication serialize under report.lock; queue-changing actions and the route acquire requests.lock first. Report writes fsync both bytes and directory, preserve acknowledgements and prior verdicts, and remain bounded by the existing 256 KiB report limit. Confirmed requests disappear from the dock across tabs, devices, reloads and daemon restarts; failed confirmation keeps the item until a successful authoritative read. No request verdict means no invented completed dock item. The report retains failedActivations across unrelated runs, derived only from journaled swaps whose candidate startup was attempted. Boot repair uses the injected read-only receipt store to distinguish publisher from customer identity and compares the failed version to the cached target catalog version. Staging, licence, consent and offline refusals do not set this guard; an explicit retry that proves the same version clears it. A terminal rollback journal also supplies the guard if the report write was lost.

Release comparison and CLI readiness

src/shared/version.ts owns isNewer(candidate, current) for the core updater, marketplace compatibility checks and configuration API. It compares plain numeric dot segments, accepts an initial v and pads missing segments with zero; it does not introduce prerelease ordering. Callers keep their existing release validation. Comparison is pure and performs no package or network reads.

CLI doctor uses the existing urlHealthy(url, fetchFn, timeoutMs) launcher probe (src/cli/launcher.ts:87), also used by startup and installer readiness. The default request deadline is three seconds, any non-2xx or transport/abort failure answers false, and the timer is cleared after settlement. A failed doctor probe exits before authentication or readiness reads. Licence activation keeps its distinct injected fetchHealth response seam because it inspects the locked response body rather than a boolean.

Boot proof contract

Before stopping services for a core update, runUpdateTransaction reads pluginProofInputs, wired by defaultTransactionIO to the current and staged package's dist/plugins, the instance user-plugin root and the staged core's manifest parser. It collects folder names and parsed manifest names from both candidate loader roots. A renamed folder retaining a baseline manifest name stays required even though the loader cannot load it. A user copy exposed by bundle removal also stays required. Manifest parse failures are logged and retain the folder name, so a broken present entry never proves retirement. An unreadable existing scan root fails staging; an absent user root is allowed, matching the loader. The scan reads each root and its manifests once before swaps.

Only loaded baseline names whose current bundled folder has a manifest and whose name is absent from both candidate name sets enter retiredBundledPlugins in the durable journal. Plugin-only transactions do not scan or exclude bundles.

Forward proof subtracts these retired bundles from the baseline, then keeps every enabled recorded plugin swap required as before. Restore proof subtracts a retired bundle only if candidate startup was attempted and no registry swap recorded that name: candidate boot may have migrated its configuration, and restoring package files does not reverse database migrations. Failures before candidate startup still require the complete restore baseline. Recovery reads the same journaled retirement facts without reopening swapped package paths. The baseline itself stays intact, and the journal names every retired bundle for operator diagnosis. Outcome notes also name excluded bundles, including successful, rolled-back and operator-required outcomes; normal publication and recovery copy those notes into the report. Registry-plugin and independent-service proof expectations retain their existing strictness.

The daemon writes BootReport through writeBootReport(updateDir, report) after registry construction and again after plugin services settle. Each service result contains only name and ok; start failures remain false even if a later service probe could report an active unit. Failure diagnostics stay in the existing service runner logs. The report retains schema, release version, PID, boot start time, loaded plugin names and servicesSettled. readBootReport returns null when the file is absent, rejects invalid proof fields, enforces the existing 256 KiB file bound and projects service results to name/status facts. awaitBootProof, consumed by the update transaction, checks freshness, version, loaded plugins, settled service success and daemon health within its existing proof deadline. The file proves this boot; it is not a service diagnostic store or an expanded public health response.

Privileged step input

readCoreStepRequest(stream) in src/licence/releases.ts validates the updater's privileged-step JSON request from stdin with a fixed 8192-byte module limit. readBounded enforces that limit as bytes arrive; oversized, malformed or missing requests throw. Stage requests carry the transaction id, JWS token and pinned release version; install requests require the transaction id and version, while refresh, commit and restore require an id and clean requires neither id nor token. runInstallReleaseStep in src/cli/rootUpdateStep.ts is the real consumer. There is no caller-selectable size override, alternate input path or success default for absent data.

Installed host convergence

src/cli/install/hostConvergence.ts owns installed unit/inventory convergence. Candidate selection, lock modes and queue draining stay in src/cli/update.ts; core inventory reads stay in src/update/core.ts. The coordinator retains its refreshAfterUpdate collaborator. Root refresh re-executes the installed CLI through rootUpdateStep.refreshInstalledHost, without compatibility re-exports or a second installation-record owner.

Release path and restore ownership

src/update/core.ts owns the release root, release path and release-name parser. Names are CORE_VERSION_RE followed by a hyphen and UPDATE_TRANSACTION_ID_RE; staging uses the same transaction grammar. Pruning retains current and previous releases and still requires the existing inventory and release-marker ownership proofs. Foreign names are ignored and altered or unrecorded candidates are refused without deletion. recordCoreInventory(packagesDir) derives coreInventoryPath internally. restoreCoreSwap(recordPath, packagesDir, id) requires the transaction id and returns null for an absent or mismatched record. Root update steps and the in-process update coordinator use these signatures; installer calls remain unchanged.

Licensed release feed and pinned downloads

Core releases and plugin payloads come from the licensing server under /hooks/licensing/v1. The routes used by updates are core/latest (the newest release descriptor), core/<version>.json (the descriptor of one pinned version), core/<version>.tgz (the core artifact), plugins/<name>.tgz?version=<version> (a plugin payload, where the version is always required) and update/signal (the force-update revision). A descriptor carries version, sha256 and size, and size is at most 512 MiB. Metadata requests share one 15-second deadline. Artifact downloads have a separate 5-minute deadline, and the downloaded bytes are checked against the pinned sha256 and size while they stream.

Choosing a release and installing it are separate steps. The CLI picks the newest release from core/latest. The privileged stage step then resolves that selection only through core/<version>.json, from src/licence/releases.ts. If that answer names another version, or the route answers 404, the result is unknown and nothing is installed. A 401 or 403 answer is refused, never "up to date". A release published after the selection waits for the next run, so the payload always matches the descriptor the selection was made from.

The force-update signal carries only a revision number. It names no version, plugin or hash. A newer revision makes the automatic updater run its ordinary licensed check now, so the feed still decides what gets installed.