* format: let a signed save bind a container's parts The shared container writer can now record each authoritative part's SHA-256 while writing it (track_parts/take_parts). A blob's name already is its digest, so the writer reuses it instead of hashing the payload again. Provenance records use this to bind a design without reading the file back. No format change: this is an API addition used by signed saves. * Design containers: optional signed provenance records A design container may carry provenance/log.cbor: a bounded chain of signed records whose manifests bind the exact bytes of document.json, container.json and every authoritative payload by SHA-256. Records are COSE_Sign1 with Ed25519 for software keys, or a detached OpenPGP signature with the signer's public key, which GnuPG produces for a key that may be on a smartcard. Verification of both is in-process (coset/ed25519-dalek for COSE, rPGP for OpenPGP), so it needs no keyring and works in the browser; the private key never enters the file. The chain is append-only and bounded: prev links bind the exact record bytes, oldest pins the newest record to the file's first record so a stripped prefix is detected, and a keyless save keeps the history and is reported as not covering the current edition. Caches and previews are deliberately excluded. Reading the entry never fails a design open; a newer envelope version is reported, not misread. The writer reuses the container's new part-digest tracking, so signing does not read a payload twice. No design-format change: the entry is additive and optional. * Designs: sign saves with GnuPG and verify a design's signature file.save and file.save_as take sign: true and signing_key: each appends one OpenPGP record to the design's provenance and reply with the signing key's fingerprint and user id. GnuPG signs the manifest where the key lives, so a YubiKey OpenPGP smartcard asks for its PIN through pinentry and no secret key is copied; the save still runs on the worker thread. A save without a key keeps the records it opened with, so the newest record is reported as covering an earlier version. file.verify reports the signature of a saved design (status, records, signer, coverage, truncation) without opening it, and file.gpg_keys lists the GnuPG secret keys a save can use, marking those on a smartcard. A design opened with verify: true carries the report into doc.open's reply. The session keeps the provenance chain of the file it opened, so saves append to the right history; opening any design file loads that chain. The browser cannot run GnuPG and says so when asked to sign. * Preferences: Security page for signing, and signature indicators Preferences gains a Security page, off by default: sign saved designs with GnuPG, the signing key (empty uses GnuPG's default), and check signatures when opening designs. Saves in the app pass the choices to file.save; opening passes verify: true. The status line says who signed a save or what a check found, and a signed design's tab tooltip names its signer (or says it has not been checked in this session). The browser has the page and can verify, but signing says GnuPG is needed. Encryption is described as the next step and is not a setting yet. * Designs: icons for the signature commands file.verify and file.gpg_keys had no icon assignment, which the icon standards gate refuses. Both use the existing shield icon. * Signing: check on request, choose a key, warn when a save is not signed File > Verify Signature (the application bar's menu and the macOS menu) checks the open design's saved file on a worker thread and reports what it found in the status line; the tab tooltip then keeps that result. It is disabled for a design with no file yet, and the answer is discarded when the tab changed meanwhile. The Security page gains Choose… next to the signing key: it lists the secret keys GnuPG can sign with (file.gpg_keys), marks those on a smartcard and fills the field with the chosen fingerprint. Refresh lists them again; GnuPG missing or having no keys is said where the list would be. An unsigned save of a design that carries earlier signatures now says so in the status line: the earlier signature does not cover this save. Before, only file.verify reported that. The status of a check and the menu row are tested; the warning has a unit test. * Signing: show a tab shield, a Home hint, and a linked-design note Each design tab now carries a shield beside its name, not only tooltip text: grey when the file has records that were not checked this session, green when they verify and cover it, amber when an earlier signature no longer covers the file or the record format is newer, red when a check failed. The colours are the theme's existing status tokens. Home reads each recent design's provenance entry (never the payloads) and adds 'signed' to its kind, with a hover line naming the newest signer, so a design can be recognised as signed before it opens. The native Open dialog still lists names only; this is the in-app list. Inserting a linked design now says that the linked file is signed (not checked); exports carry no provenance, because only a saved .sc container has the entry, and the docs say so. The pre-open summary is a new io API (records and newest signer without verification), with its own test; the tab mapping, the Home label and the existing provenance tests cover the UI paths. * Signing: fix the review findings, from staged checks to background keys Staged design opens now compute the requested verification on their worker and pass it to the landing session, instead of hard-coding false and hashing the payloads on the window thread. file.verify reports a file that cannot be read as an error rather than as unsigned, and checking an explicit other path no longer replaces the active design's report. read_chain drops a chain that does not validate, so the next save starts a new log instead of failing to append to it. A JSON save keeps the provenance chain until it succeeds; the success path already clears it from the written bytes. Save As JSON omits the signing parameters. With Check signatures on opening on, an already-open design is reused and its saved file checked separately, rather than opening a second tab. The background check refuses a report when a save replaced the file while it ran. Home tags recent-file summaries with a per-path generation so an older response cannot overwrite a newer one. The signing-key list is read on a worker (a GnuPG subprocess can wait on an agent or a smartcard), with a loading state in Preferences. Staged verification and the pre-open summary keep their new tests. The complete catalogs (ar, he, ja, vi, zh) carry the new messages. * Accept rsa/crypto-bigint advisories for local signing; exempt file.verify and file.gpg_keys from the kernel rig Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Signing: address the human review, from trust and signing policy to CI The human review found three blocking problems and several smaller ones. Trust: a valid signature no longer earns the green shield or a user id on its own. Only a fingerprint in the new Preferences › Security › Trusted signing keys list is shown verified; every other valid signature is grey, with its fingerprint and 'key not trusted'. The list is local and empty by default. OpenPGP verification now checks the embedded key's self-signatures and a signing subkey's binding (and back-signature), and reports the primary key so trusting it covers its subkeys. Home's pre-open hint shows the fingerprint unless the key is trusted. Cache: a design file that carries provenance never adopts its model cache, checked or not, so the geometry shown and exported comes from the signed timeline rather than from unprotected cache data. Signing policy: only the app's own Save carries the signing grant, set on the save worker's session. A script, MCP or control caller that sends sign: true is refused. The command line still verifies but cannot sign in this version; the docs say why. CI and format: the two new commands have their EXEMPT lines naming their tests; the advisory ignores document that rsa/crypto-bigint verify public keys only and never see a private key. GPG lookup ignores relative PATH entries and searches standard locations first. The docs now cover the embedded public key's user ids, the missing newest-record protection, the cache rule, the additive entry (no design-format pending file), and the trust policy. New tests cover the refusals, trust matching, provenance presence, and the missing-file check. * Signing: a changed design is not shown as checked After an edit, the last report covers the saved file, not the document in front: the tab keeps its shield grey and the tooltip says the design changed since it was checked, and the badge test now covers the trusted and untrusted cases. * Preferences: the Security page uses the UI kit's controls The key picker's Choose/Refresh buttons and key rows are UI kit buttons and list rows; the UI kit guard requires it, and the native test suite runs that guard. * Signing: accept fingerprints as GnuPG prints them, and test bindings The trust list split on whitespace, so a fingerprint pasted from gpg --fingerprint was chopped into groups that never matched and the key stayed silently untrusted. Entries are now separated by commas or newlines, and spaces inside an entry are ignored, so the grouped form works. verify_openpgp's comment no longer says bindings are not evaluated. The in-process software signer now has a signing subkey and an unbound variant: a bound subkey verifies under the primary fingerprint, and a subkey with its binding removed is rejected. A record with a spoofed user id shows why the trust decision uses the fingerprint, not the name. * Space mouse test: keep the large app values on the heap The test held several app values on its stack at once. The signing state added a little to an already large app, and the test thread's stack overflowed; boxing them keeps it well clear. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brennen Herbruck <brennen.herbruck@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
solidcraft-format
Versioned file formats: what an app needs to read every file it ever wrote, to open a file a newer build of it wrote without losing what it can't read, and to package a file with its binary payloads. It holds no app types; each app registers its own families of files and their versions, and keeps its own payload encodings.
A file starts with "format": "<prefix>N". A family's versions are, in order: the steps written
out by hand for its first formats, then one folder per format (v<N>/, one file per change, each
with a JSON step when older files are rewritten as they open), then the changes in flight
(pending/), which make the next number until a release folds them into v<N>/. App code only
knows the current format: all translation lives in the steps.
API
| Item | What it does |
|---|---|
Family { app, noun, prefix, legacy_prefix, untagged, aliases, formats } |
One kind of file: its tag, its messages, its versions |
Formats { hand, folded, pending } |
A family's versions: hand-written steps, folded formats and their changes, pending changes |
Part { name, step } |
One change: its file's name, and its JSON step if it has one |
Step<E> |
fn(&mut Value) -> Result<(), E>: a JSON-level step, returning the app's error |
Alias { format, name, known_as } |
Another spelling of a change name that files of one format may carry |
Family::current() |
The format this build writes |
Family::format_string(n), Family::version_of(tag) |
A format's tag, a tag's format (the legacy prefix read too) |
Family::pending_names() |
The changes in flight, written into a file's format_pending |
Family::take_version(&mut json) |
A file's format and its format_pending, taken out before anything else is read; a file not of the family is refused |
Family::newer(version, applied) |
Some(Newer) when a newer build wrote the file: a number past this build's, or a change it doesn't know |
Family::upgrade(&mut json, version, applied) |
Run the steps that bring the file to this build's format, each once |
Newer { format, reads_up_to, unknown_changes } |
How a file shows a newer build wrote it |
FormatError |
This crate's refusals; the app's error takes it with From |
build::lists(manifest, families) |
For a build script: the generated FOLDED and PENDING lists and a module per change file |
newer::NewerFile |
What a file a newer build saved holds that this build doesn't read: new(app, &newer), notice, warnings, report, keep_section, keep_element, compare (the file against what was read of it), restore_into (put it all back on saving), mentions, needs_container, and the container's unknown parts and container |
newer::leave_out_unreadable, newer::read_around |
Leave out, and keep, the top-level sections a file can't be read with |
newer::short, newer::looks_default |
A reader's error's first line; a value a writer leaves out |
container::Writer |
A ZIP container being written: part, blob (content-addressed, blobs/<sha256>.<ext>), room/space for optional caches, finish |
container::Reader |
One being read over a seekable source, its directory checked on opening: document, limited_part, blob, limited_blob, has_part, part_names, hydrated |
container::Limits |
The app's budgets: entries, expanded size, per-part limits, Deflate level, file size |
container::Compression, container::Thumbnail, container::digest, container::is_container |
Fast or compact payloads; a PNG picture keyed to its file; the SHA-256 name of bytes; the ZIP signature |
Registering a family
The app's build script lists the folders:
use solidcraft_format::build::Family;
// format 1 is the first record, so its folded formats start at 2 (`base` is the last format before them)
const FAMILIES: [Family; 1] = [Family { folder: "src/format", base: 1, list_prefix: "" }];
let lists = solidcraft_format::build::lists(&manifest, &FAMILIES)?;
// write lists.code to OUT_DIR, rerun on each of lists.watch
and its format module includes them, with its error type:
type Part = solidcraft_format::Part<AppError>;
include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
pub const DESIGN: solidcraft_format::Family<'static, AppError> = solidcraft_format::Family {
app: "App", noun: "design", prefix: "app/", legacy_prefix: None, untagged: None, aliases: &[],
formats: solidcraft_format::Formats { hand: &[], folded: FOLDED, pending: PENDING },
};
A change file is v<N>/<name>.rs or pending/<name>.rs, named with lowercase letters, digits and
dashes, starting with a //! note (what is saved, what an older file gets, what the last release
does with a file that has it), and with pub fn step(v: &mut Value) -> Result<(), AppError> when
older files are rewritten as they open.