Repository navigation
Update core: embedded identity, durable trust promotion, check and observed state - #5
immakermatty wants to merge 14 commits into
Conversation
…lity, no-wedge core) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…known-good binary, atomic publishing Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…l document, durable writes, crash-safe lock The future home of the update mechanism is src/update/; src/distribution/ stays untouched. One table defines every error code with its exit status and retryability. The identity module is the only reader of the build-time defines and falls back to a development identity that sorts below every release. The channel parser keeps the pilot parser's invariants for the stable and preview channels and adds the version a check compares. The lock is one flock-ed regular file polled to a timeout: nothing to initialize, nothing to wedge. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the observed state The pinned tuf-js refreshes in a scratch copy seeded from trust/. When the refresh ends - success or failure - every newly verified role is promoted by temporary file, sync, rename and directory sync: numbered root chain, root, timestamp, snapshot, targets, then the channel floor. trust.ts records exactly when tuf-js 6.0.0 persists relative to verification, with line references; promotion re-verifies the root chain itself and withholds the role delivered last in a failed refresh. checkForUpdate returns a typed result with stable error codes and never throws for expected failures; update/observed.json is derived state that a missing or corrupt file can never turn into a wedge. Tests run against a signed consistent-snapshot loopback repository: rotation retained across a failed refresh, a kill before every single promotion, expired timestamp/snapshot/targets/root, repository advance and channel rollback, unknown files, concurrent checks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…embed their identity build-candidate passes the identity as --define values and, for a native build, asks the produced executable to confirm it equals identity.json; the candidate smoke asserts the same. A unit test compiles the real CLI entrypoint with the defines and compares --version. Plain 'lazurio update' reports the typed not-implemented error: this build can only check. docs/update.md records what the first slice implements and the concrete choices it fixed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… promotion; exact lock.ts references Floors are read after promotion, so a floors file of a newer schema still lets a verified root rotation become durable. The TODO in src/update/lock.ts now names the exact lines of src/folder/lock.ts that prevent sharing it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| } | ||
| const clock = environment.clock ?? (() => new Date()); | ||
| const env = environment.env ?? process.env; | ||
| const base = |
There was a problem hiding this comment.
🟡 Medium update/cli.ts:133
Plain lazurio update returns invalid-request (exit 2) instead of the promised not-implemented (exit 31) when HOME is unset or the platform has no default base, such as win32. The command resolves and validates the default install base before checking values.check, even though this no-op path does not use the base; dispatch the no---check path before default-base resolution, while retaining validation for an explicitly supplied base if required.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/cli.ts around line 133:
Plain `lazurio update` returns `invalid-request` (exit 2) instead of the promised `not-implemented` (exit 31) when `HOME` is unset or the platform has no default base, such as `win32`. The command resolves and validates the default install base before checking `values.check`, even though this no-op path does not use the base; dispatch the no-`--check` path before default-base resolution, while retaining validation for an explicitly supplied `base` if required.
| const base = | ||
| values.base ?? | ||
| resolveInstallBase({ | ||
| platform: environment.platform ?? process.platform, | ||
| env, | ||
| homedir: env.HOME, | ||
| }); | ||
| if (!base || !isAbsolute(base) || resolve(base) !== base) | ||
| return render(failure("invalid-request", { option: "base" }), json); |
There was a problem hiding this comment.
🟠 High update/cli.ts:133
Valid absolute directory paths such as /tmp/lazurio/ are rejected with invalid-request, so both update status and update --check fail for documented inputs. The resolve(base) !== base check compares raw spelling instead of path meaning; normalize base after validating that it is absolute, then pass the normalized value onward.
- const base =
+ let base =
values.base ??
resolveInstallBase({
platform: environment.platform ?? process.platform,
env,
homedir: env.HOME,
});
- if (!base || !isAbsolute(base) || resolve(base) !== base)
+ if (!base || !isAbsolute(base))
return render(failure("invalid-request", { option: "base" }), json);
+ base = resolve(base);🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/cli.ts around lines 133-141:
Valid absolute directory paths such as `/tmp/lazurio/` are rejected with `invalid-request`, so both `update status` and `update --check` fail for documented inputs. The `resolve(base) !== base` check compares raw spelling instead of path meaning; normalize `base` after validating that it is absolute, then pass the normalized value onward.
| .map(Number); | ||
| const pre = dash === -1 ? [] : value.slice(dash + 1).split("."); | ||
| return { core, pre }; | ||
| }; | ||
| const a = split(left); | ||
| const b = split(right); | ||
| for (let index = 0; index < 3; index++) { | ||
| const difference = (a.core[index] as number) - (b.core[index] as number); |
There was a problem hiding this comment.
🟡 Medium update/version.ts:14
compareVersions can return 0 for distinct valid releases, and can return 1 when comparing identical sufficiently large core components. split converts arbitrarily long components with Number, causing rounding or Infinity - Infinity (NaN), which the ordering branch treats as 1; compare normalized decimal strings instead.
- .map(Number);
+ .map((part) => part.replace(/^0+(?=\d)/, ""));
const pre = dash === -1 ? [] : value.slice(dash + 1).split(".");
return { core, pre };
};
const a = split(left);
const b = split(right);
for (let index = 0; index < 3; index++) {
- const difference = (a.core[index] as number) - (b.core[index] as number);
- if (difference !== 0) return difference < 0 ? -1 : 1;
+ const x = a.core[index] as string;
+ const y = b.core[index] as string;
+ if (x.length !== y.length) return x.length < y.length ? -1 : 1;
+ if (x !== y) return x < y ? -1 : 1;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/version.ts around lines 14-21:
`compareVersions` can return `0` for distinct valid releases, and can return `1` when comparing identical sufficiently large core components. `split` converts arbitrarily long components with `Number`, causing rounding or `Infinity - Infinity` (`NaN`), which the ordering branch treats as `1`; compare normalized decimal strings instead.
| /** Owned security state must not be replaceable by another account. */ | ||
| export async function ensureOwnedDirectory(path: string): Promise<void> { | ||
| await mkdir(path, { recursive: true, mode: 0o700 }); | ||
| const stat = await lstat(path); |
There was a problem hiding this comment.
🟠 High update/trust.ts:101
An attacker who can modify the parent of trustDirectory can replace trust/ after ensureOwnedDirectory returns, causing readSeed to read an attacker-controlled root.json and accept it if it is self-signed. Because subsequent reads and promotions reopen paths by name, this replaces the TUF trust anchor and can authenticate forged metadata; validate and hold a trusted parent or use descriptor-relative no-follow operations for the entire refresh.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/trust.ts around line 101:
An attacker who can modify the parent of `trustDirectory` can replace `trust/` after `ensureOwnedDirectory` returns, causing `readSeed` to read an attacker-controlled `root.json` and accept it if it is self-signed. Because subsequent reads and promotions reopen paths by name, this replaces the TUF trust anchor and can authenticate forged metadata; validate and hold a trusted parent or use descriptor-relative no-follow operations for the entire refresh.
| const path = observedPath(base); | ||
| if ((await stat(path)).size > 64 * 1024) throw new Error("Too large"); | ||
| return parseObserved(JSON.parse(await readFile(path, "utf8"))); |
There was a problem hiding this comment.
🟠 High update/observed.ts:276
readObserved hangs indefinitely when update/observed.json is a FIFO or other special file, so update status can hold update/lock and cause subsequent checks to fail as busy. stat() only checks the reported size; require stat(path).isFile() before calling readFile().
const path = observedPath(base);
- if ((await stat(path)).size > 64 * 1024) throw new Error("Too large");
+ const file = await stat(path);
+ if (!file.isFile() || file.size > 64 * 1024) throw new Error("Too large");
return parseObserved(JSON.parse(await readFile(path, "utf8")));🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/observed.ts around lines 276-278:
`readObserved` hangs indefinitely when `update/observed.json` is a FIFO or other special file, so `update status` can hold `update/lock` and cause subsequent checks to fail as `busy`. `stat()` only checks the reported size; require `stat(path).isFile()` before calling `readFile()`.
| if ( | ||
| !isAbsolute(input.base) || | ||
| resolve(input.base) !== input.base || | ||
| !isUpdateChannel(input.channel) || | ||
| !validUrl(input.metadataBaseUrl) || | ||
| !validUrl(input.targetBaseUrl) | ||
| ) |
There was a problem hiding this comment.
🟠 High update/check.ts:179
Passing operationId: "x/../../trust" makes checkForUpdate recursively delete <base>/trust in its finally block, destroying durable trust state; the same traversal also directs scratch operations outside update/. Validate caller-provided IDs as a single safe path component before constructing scratch (or derive the directory name from a generated safe identifier).
if (
- !isAbsolute(input.base) ||
+ !isAbsolute(input.base) ||
resolve(input.base) !== input.base ||
!isUpdateChannel(input.channel) ||
!validUrl(input.metadataBaseUrl) ||
- !validUrl(input.targetBaseUrl)
+ !validUrl(input.targetBaseUrl) ||
+ (input.operationId !== undefined &&
+ !/^(?!\.{1,2}$)[^/\\]+$/.test(input.operationId))
)🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/check.ts around lines 179-185:
Passing `operationId: "x/../../trust"` makes `checkForUpdate` recursively delete `<base>/trust` in its `finally` block, destroying durable trust state; the same traversal also directs scratch operations outside `update/`. Validate caller-provided IDs as a single safe path component before constructing `scratch` (or derive the directory name from a generated safe identifier).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… durable capture of authenticated failure-state floors Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nto claude/DEV-6594-update-core
…ing the last-delivered role The pinned tuf-js authenticates a newer timestamp or snapshot, installs it in memory and only then throws on expiry (store.js:89-91, :133-136); the Updater never persists it, so the rollback floor died with the process and a later replay of a lower, still valid version passed. The fetcher now retains the raw bytes of the role delivered last; after a failed refresh promotion re-verifies it without the client and without expiry (signature threshold under the promoted root, version rules of the store, and for a snapshot the length, hashes and version recorded by the newest authenticated timestamp) and writes it as the ordinary role file. An expired local timestamp or snapshot is only a version floor for the client (updater.js:195-206, :230-234); expired targets carry no floor (store.js:172-175) and are never captured. Expired metadata still authorizes nothing. Tests: expired newer timestamp and snapshot are kept, a replay of the last valid lower generation is refused (for the snapshot even with the timestamp file lost), a fresh higher generation is accepted, unauthentic or not-newer roles are discarded, and an interrupted capture converges. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e-version binding, rotation) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nto claude/DEV-6594-update-core
…nd every target lookup Per-role version floors in the role files are not enough: they die with a lost file, with an interruption, and with a key rotation (a retained role signed by a revoked key no longer loads in the pinned client). trust/floors.json keeps the facts themselves: root versions and digests, the timestamp and its snapshot reference, the snapshot and EVERY snapshot.meta entry, and version + signed-content digest of each verified role. Signed-content digest = SHA-256 of canonicalize(signed.toJSON()), exactly what @tufjs/models verifies a signature over; @tufjs/canonical-json becomes a declared dependency. Every candidate — after a failed refresh the changed roles and the authenticated last-delivered one, after a successful refresh every role the client trusts — is compared with the whole vector: lower version, same version with other content, lowered snapshot reference, removed/lowered/changed snapshot.meta entry, contradicting root. A violation is `metadata-rollback` (exit 47) and keeps only the valid root chain. floors.json is written FIRST, then the files, so the vector is never lower than what the files prove and a vector that is ahead cannot wedge. Targets are looked up only after a refresh that completed without error and satisfied the vector. A missing or damaged vector is rebuilt from the retained files under the retained roots. tests/update-floors.test.ts ports the proofs of tests/historical-roles.test.ts to the new path, including all rules after a rotation of every role key and after an interruption — two cases in which, with the comparison switched off, the pinned client accepts the lower state. src/distribution is untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| const opened = await handle.stat(); | ||
| const current = await lstat(path).catch(() => undefined); | ||
| // Otherwise an inode that is no longer the lock was locked: retry. | ||
| if ( |
There was a problem hiding this comment.
🟠 High update/lock.ts:84
The inode check does not prevent concurrent updates: if path is replaced immediately after the comparisons at lines 84–87, this caller keeps the flock on the old inode while another caller locks the new pathname, so both enter the update critical section and can concurrently promote trust or clean scratch. Hold a stable directory descriptor and coordinate replacement, or revalidate at the mutation boundary with a replacement-safe scheme.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/lock.ts around line 84:
The inode check does not prevent concurrent updates: if `path` is replaced immediately after the comparisons at lines 84–87, this caller keeps the `flock` on the old inode while another caller locks the new pathname, so both enter the update critical section and can concurrently promote trust or clean scratch. Hold a stable directory descriptor and coordinate replacement, or revalidate at the mutation boundary with a replacement-safe scheme.
| Number.isSafeInteger(value.length) && | ||
| Number.isSafeInteger(value.sequence) && |
There was a problem hiding this comment.
🟡 Medium update/observed.ts:143
parseObserved accepts available.length <= 0 and available.sequence <= 0, so a corrupt observed.json such as { "length": -1, "sequence": 0 } is retained and displayed as an available update instead of triggering the documented rebuilt-observation fallback. Require both fields to be positive in isAvailable, matching the values produced by successful checks and valid channel documents.
- Number.isSafeInteger(value.length) &&
- Number.isSafeInteger(value.sequence) &&
+ Number.isSafeInteger(value.length) && value.length > 0 &&
+ Number.isSafeInteger(value.sequence) && value.sequence > 0 &&🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/observed.ts around lines 143-144:
`parseObserved` accepts `available.length <= 0` and `available.sequence <= 0`, so a corrupt `observed.json` such as `{ "length": -1, "sequence": 0 }` is retained and displayed as an available update instead of triggering the documented rebuilt-observation fallback. Require both fields to be positive in `isAvailable`, matching the values produced by successful checks and valid channel documents.
| if (!url.startsWith(this.metadataBaseUrl)) return undefined; | ||
| // Consistent snapshots prefix the version: `7.snapshot.json`. | ||
| const name = url.slice(this.metadataBaseUrl.length); |
There was a problem hiding this comment.
🟠 High update/trust-fetcher.ts:51
metadataFile rejects every request when the configured base contains an explicit default port, so root bytes are not recorded and timestamp/snapshot bytes are not retained as rollback floors. The updater's URL serialization removes :443 (or :80) before calling this method, making the raw startsWith(this.metadataBaseUrl) check fail; compare the parsed URL origin and pathname instead.
- if (!url.startsWith(this.metadataBaseUrl)) return undefined;
+ const request = new URL(url);
+ const base = new URL(this.metadataBaseUrl);
+ const basePath = base.pathname.endsWith("/")
+ ? base.pathname
+ : `${base.pathname}/`;
+ if (
+ request.origin !== base.origin ||
+ !request.pathname.startsWith(basePath)
+ )
+ return undefined;
// Consistent snapshots prefix the version: `7.snapshot.json`.
- const name = url.slice(this.metadataBaseUrl.length);
+ const name = request.pathname.slice(basePath.length);🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/update/trust-fetcher.ts around lines 51-53:
`metadataFile` rejects every request when the configured base contains an explicit default port, so root bytes are not recorded and timestamp/snapshot bytes are not retained as rollback floors. The updater's `URL` serialization removes `:443` (or `:80`) before calling this method, making the raw `startsWith(this.metadataBaseUrl)` check fail; compare the parsed URL origin and pathname instead.
| anchor the TUF client is seeded with; `<N>.root.json` is the retained verified | ||
| chain; `timestamp.json`, `snapshot.json`, `targets.json`; | ||
| `channel-floors.json` (`schemaVersion`, per-channel `sequence` and | ||
| `documentSha256`). Only these names are read; anything else is ignored. | ||
| Promotion order is the numbered chain, `root.json`, timestamp, snapshot, | ||
| targets, then the floor, and `trust/` always holds a prefix of that order. |
There was a problem hiding this comment.
🟢 Low docs/update.md:350
This section documents an incorrect trust layout and write order: it omits trust/floors.json even though the implementation reads it, and places the unspecified floor after the roles even though floors.json is written first. This makes the documented security-state ordering contradict src/update/floors.ts and src/update/trust.ts; list both floor files and distinguish their ordering.
- `root.json` is the current trusted root and the only
- anchor the TUF client is seeded with; `<N>.root.json` is the retained verified
- chain; `timestamp.json`, `snapshot.json`, `targets.json`;
- `channel-floors.json` (`schemaVersion`, per-channel `sequence` and
- `documentSha256`). Only these names are read; anything else is ignored.
- Promotion order is the numbered chain, `root.json`, timestamp, snapshot,
- targets, then the floor, and `trust/` always holds a prefix of that order.
+ `root.json` is the current trusted root and the only
+ anchor the TUF client is seeded with; `<N>.root.json` is the retained verified
+ chain; `timestamp.json`, `snapshot.json`, `targets.json`;
+ `channel-floors.json` (`schemaVersion`, per-channel `sequence` and
+ `documentSha256`); `floors.json` (the rollback floor vector). Only these
+ names are read; anything else is ignored. Write order is `floors.json`, the
+ numbered chain, `root.json`, timestamp, snapshot, targets, then
+ `channel-floors.json`; `trust/` always holds a prefix of that order.🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @docs/update.md around lines 350-355:
This section documents an incorrect trust layout and write order: it omits `trust/floors.json` even though the implementation reads it, and places the unspecified floor after the roles even though `floors.json` is written first. This makes the documented security-state ordering contradict `src/update/floors.ts` and `src/update/trust.ts`; list both floor files and distinguish their ordering.
…o-op once in 256 runs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Motivation
docs/update.md(PR #4) is the accepted contract for how an installed Lazurio learns about, obtains and activates a new version. This is its first implementation slice: the part that must be dependable before anything else can be — who am I, what do I trust, and is there something newer. It replaces nothing yet; it lands beside the pilot installer so the contract's "prove it, then delete the journal" order can be followed.Nothing here is deployed to clients, so there is no compatibility burden.
What changes
New module directory
src/update/, the future home of the mechanism.version,commit,targetare embedded with Bun--define;src/update/identity.tsis the only reader and falls back to a development identity (0.0.0-development) that sorts below every release.lazurio --version [--json].scripts/build-candidate.tspasses the defines from the same values that go intoidentity.jsonand, for a native build, asks the produced executable to confirm they are one fact;scripts/smoke-candidate.tsasserts the same.tuf-jsrefreshes inupdate/scratch-<id>/seeded fromtrust/. When the refresh ends — success or failure — every newly verified role is promoted by temp file + fsync + rename + directory fsync: numbered root chain,root.json, timestamp, snapshot, targets, then the channel floor.trust/always holds a prefix of that order. Scratch is always deleted.src/update/trust.ts. Timestamp, snapshot and targets are persisted only after complete verification including expiry (updater.js:213→225,:249→251,:285→287; the expiry throws atstore.js:91,:136,:172precede the persist). The root is persisted (updater.js:178) after complete authenticity verification (store.js:42-48) but before the final-root expiry check, which only happens when a timestamp is loaded (store.js:57-59). It is promoted regardless, deliberately — see "Deviations".Promotion does not rely on that reading alone: it re-verifies the root chain link by link with
@tufjs/models, and it withholds the role that was delivered last in a refresh that then failed (the client fetches strictly sequentially, so a later request is the proof that the previous phase returned).sequence, document sha256 per channel) live intrust/channel-floors.jsonas schema-versioned security state, advanced only after the signed document was verified and only forward. Channels arestableandpreview.checkForUpdatereturnsup-to-date | available{version, artifactSha256, length, sequence} | error{code, context}and never throws for expected failures. Error codes, exit statuses and retryability are one table insrc/update/errors.ts. No product mutation, no artifact download.update/observed.jsonis rewritten atomically after every check.readObserved()returns a rebuilt minimal observation for a missing, corrupt, oversized or unknown-schema file; nothing reads it as authority.lazurio update --check [--json],lazurio update status [--json], with explicit--metadata-url,--target-url,--channel,--bootstrap-root,--base,--loopback-fixture. Exit0up to date,10available, a distinct status per error code, and the code is printed. Plainlazurio updatereturns the typednot-implemented(31) and touches nothing.update/lock, one regular file underflock, polled to a timeout (busy).O_CREATis atomic and the content is never read, so there is no initialization to interrupt.What deliberately does not change
src/distribution/*(journal, replay, recovery cycles,productcommands) is untouched and still passes its tests. It is deleted only after the contract's interruption/expiry/rotation evidence exists.src/folder/lock.tsis not modified (findings below).update/config.json, no Launchpad poller or pill, no publisher.Verification
bun run checkon the pushed HEAD: lint (biome--error-on-warnings), typecheck, 451 pass / 3 skip / 0 fail across 79 files (3408 expect calls), public guard, proof build, proof smoke — all green. Baseline on the design branch was 416 pass / 3 skip, so this adds 35 tests in 6 files.Tests run against a signed loopback repository built for this slice (
scripts/update-fixture.ts: consistent snapshots, one key per role, numbered immutable objects retained). Covered:available/up-to-date;trust-conflict; neither →trust-missing;2.root.jsonare durable, and the next check only probes3.root.json;metadata-expired, trust untouched where nothing was verified, and recovery once the publisher republishes;channel-rollback, floors unchanged; per-channel floors;observed.jsonnever blocks; unknown entries in the base,trust/andupdate/are ignored and left alone;busywith no network and no observation write; aSIGKILLed holder in another process releases immediately;Mutation spot-checks confirmed the tests fail when promotion is skipped on failure, when roles are promoted before the root, when the floor check is removed, and when the unsettled guard is removed. I also built a real candidate with
scripts/build-candidate.tson darwin-arm64 and ranscripts/smoke-candidate.tsagainst it (not part ofbun run check).Not verified: Linux (CI will be the first run there), power loss as opposed to process death, and any filesystem other than local APFS.
Deviations from
docs/update.md, and whytrust-conflict.channel: "pilot"and an exact four-field set, andsrc/distribution/was to stay untouched.src/update/channel.tskeeps its invariants and addsversionandminimumVersion— a check must not download the artifact or its identity to learn the version.minimumVersion, notminimum_version. Every other field of the document is camelCase. Easy to flip before a publisher exists.runningis alwaysnull;checkedBywas added. Only the Launchpad can say what the Launchpad runs. A CLI check records the identity of the executable that compared versions instead of claiming a fact it does not have.checkingis never written. A CLI check is short and a stalecheckingis exactly what the contract says must not stick.tuf-jstakesnew Date()internally (store.js:11) and offers no clock; tests publish already-expired metadata rather than moving a clock the client cannot see.docs/update.mdgained an "Implemented so far" section recording the concrete choices above.Open points
trust/root.jsonorchannel-floors.jsonthat is unreadable yieldstrust-invalidwith no automatic way out. Atomic writes make that a disk-failure case, but it is the one place this slice can refuse until a person acts. The compiled-in root gives the root a natural fallback; the floors need a decision (refuse vs. rebuild from TUF's own targets rollback protection).errno, so a filesystem withoutflocksurfaces asbusyafter the timeout instead of its own code.tuf-js's wrappedRuntimeError(updater.js:254,:290), which drops the cause. A wrong guess only swaps two retryable codes; it is qualified by a test against the pinned version.network-unavailable, becauseDistributionTransportraises one generic error for "refused, interrupted or too large".src/folder/lock.tscannot serve the contract's lock and should be fixed in its own PR: it refuses every filesystem except APFS and ext (:14-19); it wedges permanently if a process dies betweenmkdir(:23) and theprotocolmarker (:57-67), refusing at:29-32by design (:58), and a healthy second process arriving in that window gets the same hard refusal; any stray entry or half-written marker refuses too (:70-74,:84-92); and it never waits, becauseflockis called withLOCK_NBonly (native-lock.ts:16-18).FileHandlebecomes unreachable is silently released by garbage collection.src/update/lock.tsroots held handles;src/folder/lock.tsrelies on its caller keeping the returned object alive.Note
Add update check, trust promotion, observed state, and embedded identity to update system
version,check, andstatuscommands with stable error codes, distinct exit statuses, and human-readable or JSON outputparseObservedandreadChannelFloorsthrow on newer-schema or malformed files, so a manual edit to trust state with an unsupported schema version will producetrust-invalid;checkForUpdateignores durable observation-write failures as derived-state, so a full disk silently leaves a stale observation file📊 Macroscope summarized b2af76f. 17 files reviewed, 6 issues evaluated, 0 issues filtered, 6 comments posted
🗂️ Filtered Issues
Follow-up on this branch: revised contract merged, authenticated failure-state floors (head
278c449)claude/DEV-6594-update-design@5eb2e56(authority-boundary table, superseded banners, foundations docs frommain, the new floor rule of the Check step). No conflicts here.updateTimestampinstalls the new timestamp in memory (store.js:89) and only then throws on expiry (:91→:196-197);updateSnapshotdoes the same (:133,:136→:209-215);Updaternever reachespersistMetadata(updater.js:225,:251). The authenticated newer-but-expired role died with the process, and a later replay of a lower, still valid generation passed.updater.js:195-206callsupdateTimestamp(local)insidetry/catch { continue };store.js:89installs before:91throws, so the expired local timestamp stays in memory and a lower remote version (:72) or snapshot version (:83) is refused. Snapshot:updater.js:230-234callsupdateSnapshot(local, true);store.js:133installs before:136throws, and:122-131then refuses a remote snapshot with a lower targets version. Targets differ:store.js:172-175checks expiry BEFORE installing, so an expired local targets file is discarded without a trace; its version floor is the snapshot'smeta. A targets role delivered last is therefore deliberately never captured. The final expiry checks (:102,:145,:172) still run before any target lookup: expired metadata authorizes nothing.capturedFloor(src/update/trust.ts) re-verifies it with@tufjs/modelsonly, ignoring expiry — timestamp: signature threshold under the promoted root, version above and snapshot version not below the trusted one; snapshot: length/hashes recorded by the newest authenticated timestamp, signature threshold, exactly the version that timestamp names, no targets version below the trusted snapshot's — and promotes it as the ordinary role file.trust/aftermetadata-expired; replay of the last valid LOWER generation is refused (metadata-invalid) — for the snapshot also with the timestamp file lost, so the snapshot floor is proven alone; a fresh higher generation is accepted (no wedge); a damaged signature, a tampered snapshot and an authentic lower version are not promoted; a write that dies before or after the capture converges at the next check. Mutation check: with the capture switched off, five tests fail.bun run check: 453 pass / 3 skip / 0 fail.--bootstrap-rootafter such a capture answerstrust-conflict; Update: download, staging self-check, activation worker, rollback #7 replaces that rule (a supplied root is never a conflict). The fetcher keeps only the last-delivered role's bytes, not every role's — nothing else reads them.Follow-up 2: the floor vector (head
80770bc; contractd8877edmerged)trust/floors.json(src/update/floors.ts) keeps exactly the facts of the contract's table and decides every promotion and every target lookup.src/distribution/*is untouched.canonicalize(metadata.signed.toJSON())from@tufjs/canonical-json— byte for byte what@tufjs/modelsverifies a signature over (dist/key.js:39-41→dist/utils/verify.js:10).@tufjs/canonical-json@2.0.0(the version@tufjs/modelspins, already in the lockfile) is now a declared dependency.floors.json= element-wise maximum, (2) numbered root chain, (3)root.json, (4) timestamp, snapshot, targets; each write atomic, the first failure stops the rest. A fact reaches a file only after it reached the vector, so the vector is never lower than what the files prove. Interrupted after (1) the vector is ahead, which cannot wedge: it refuses only what is LOWER or DIFFERENT, the same repository state is a no-op and any later valid state exceeds it. Interrupted inside (2)–(4)trust/holds a prefix the client recovers from by itself. Tested by failing every durable write in turn.metadata-rollback(exit 47, contextrole,rule,floor,offered); only the valid root chain and its part of the vector are kept; it outranks whatever else failed in that refresh; an authentic LOWER role the client itself refused is reported under the same code.retryable: truein the sense of the error table (it ends when metadata at or above the floors is served again, no repair on the Machine) — observers should watch the code.check.ts: refresh → capture → lookup).floors.json→ rebuilt from the retained numbered roots (walked as a verified chain) and the role files, each under the newest retained root that verifies it; written back; never blocks.Where the pinned client forced more than the contract states
updater.js:231-232,store.js:107): a new timestamp naming the same snapshot version with other hashes is accepted and the old snapshot keeps being used. So a reference — the timestamp's snapshot reference, asnapshot.metaentry — that names the same version with other recorded length/hashes is refused too.snapshot.metaentry may not fall below a role of that name that was actually verified, and a snapshot not below the newest recorded reference, whichever file is lost.Proven by switching the comparison off: after a rotation of every role key, and after an interruption between vector and files, the client accepts the lower state and reports an update.
Old proof → new proof (
tests/historical-roles.test.ts→ new path)update-floors: "rule 3: a timestamp may not name a snapshot below ANY snapshot version…"update-floors: "rule 2: the same version with different signed content…" (all three roles) + "after a rotation…" case same version, other signed contentupdate-floors: rule 2, second half (key order, whitespace, signatures ignored; unknown signed member counts)update-floors: rule 1 (each role's version), rule 4 (entry removed, entry lowered); root substitution:violationOfroot rule +update-trust: "a root in scratch reaches trust/ only through a verified chain"update-floors: "the vector is an element-wise maximum…" + "interrupted after the vector and before the files…"update-floors: "a missing or damaged floors.json is rebuilt … across a rotation" (timestamp under root 2, snapshot/targets under root 1)update-floors: rule 3 (max(reference, snapshot's own version)), malformed input:parseFloorsstrictnessupdate-floors: rule 4 (refused snapshot never erases an entry; accepted one only adds)update-floors; foreign signatures:update-trust: "after a failed refresh the role delivered last…"update-check: "an expired timestamp…", "an expired snapshot…" (floor kept, nothing authorized, no wedge)update-trust: foreign/damaged signature, snapshot not the one the timestamp names, no timestampupdate-trust: chain gap / never-received link; duplicate members:parseUniqueJsoninfactsOf/parseFloors(update-floors)update-trust: "a root in scratch reaches trust/ only through a verified chain from the seed";update-check: rotation testsupdate-floors: "after a rotation that revoked every role key the client itself is blind…" (5 attacks, no target request, only the root chain kept, then no wedge)update-checkexpiry tests;update-floorsinterruption test, last partbun run checkatbe7f88d: 461 pass / 3 skip / 0 fail (one unrelated timing flake inguarded-process.test.tson the first run, green on re-run);80770bconly makes signature damage in tests deterministic.🤖 Generated with Claude Code