27 KiB
Scaffold — Architecture Decision Records
Project Structure (Single-Repo Template)
Developers need one bootstrap target that is immediately runnable and easy to modify. Use a single generated project containing contract, CLI client, configuration, and deployment scripts. Single-template onboarding is very simple.
CLI
The workflow should be discoverable for new developers. Expose one CLI surface with subcommands for init, build, deploy, and interact. One CLI improves onboarding but makes it hard to maintain backward-compatibility.
Local Runtime
Local development should work without requiring manually managed external node setup. Provide embedded localnet lifecycle commands as part of scaffold workflow. The scaffolded toolchain can start, stop, and reset a localnet environment that supports deploy and wallet-based interaction for the generated example contract.
Build Pipeline
Contract compilation should align with Rust ecosystem standards and avoid unnecessary abstraction. Use native Cargo-based build flow as the primary compilation path.
Network Configuration
Developers need explicit, editable environment targeting for local and DevNet workflows. Use environment-file based network configuration as the default model. Generated projects include env files for local and DevNet, wallet interaction settings used by deploy and wallet-based interaction commands. Env files are familiar and automation-friendly, but require strict handling to avoid credential leakage.
Portable Artefact Build is Separate from Install
The .#lgx-portable output exists to load into a released basecamp AppImage — a
different delivery path with different XDG paths and a different install mechanism
than the scaffold-managed alice/bob profiles. basecamp build-portable targets
that output and stops once the artefacts exist; it never invokes lgpm, never
mutates profile state, and never touches basecamp.state. This keeps the two
delivery flows strictly separate, trading a slight command-surface duplication for
clean boundaries.
Build-Portable Stages Symlinks in a Scaffold-Owned Directory
basecamp build-portable produces .lgx artefacts in the nix store — paths
like /nix/store/xxx-source/foo.lgx. Operators then load those into a
basecamp AppImage via its "install lgx" file picker, which opens in the
filesystem. Browsing to /nix/store/ by hand through a file dialog is
painful: the hashes are opaque, there's no ordering, and the user has to
mentally reconstruct which artefact belongs to which module.
build-portable now writes a mirror of each built artefact as a symlink
under <project>/.scaffold/basecamp/portable/<NN>-<module_name>.lgx
(or <NN>-<module_name>-<stem>.lgx when a source emits multiple outputs).
The NN prefix is a two-digit zero-padded load-order index, so the
directory lists the artefacts in the exact order basecamp needs to load
them — modules with no project-internal deps first, modules that depend
on them afterwards. Ordering is derived from each source's metadata.json
dependencies array via a topological sort among the captured
role = "project" modules; non-project deps are ignored because they're
resolved at runtime via the basecamp preinstall or package-manager
catalog, not at hand-load time.
The symlink directory is wiped and recreated on every build-portable
run. That keeps re-runs idempotent: removing a module via basecamp modules and re-running build-portable leaves no stale symlinks. The
symlinks point at live nix-store paths; the store entries themselves are
retained by their result-lgx-portable GC root at each flake root, so
scaffold's symlink does not itself pin the artefacts.
Tradeoffs: scaffold writes under .scaffold/basecamp/portable/ at
build-portable time, relaxing an earlier design goal that
build-portable never touch .scaffold/. The upside is operator
ergonomics for the one actual friction point in the AppImage test
flow — no more hunting through the nix store in a file picker.
AppImage Path is Outside Scaffold's Scope
build-portable could have tried to auto-locate a basecamp AppImage and copy
artefacts into its module directory. It does not. The scaffold does not know the
AppImage's install location, and probing filesystem heuristics would be unreliable
across Linux distributions and macOS bundles. Instead, scaffold produces the .lgx
artefacts and prints their absolute store paths; the developer copies them into
whatever AppImage they are testing against. The tradeoff is an extra manual step;
the upside is no fragile heuristics and no surprise writes into user-managed
locations.
Module Identity Lives in scaffold.toml
Runtime IPC composition means a module declares dependencies on other modules by
name (in metadata.json's dependencies array). Scaffold needs a mapping from
those names to concrete flake refs in the captured module set — otherwise
basecamp install cannot know which flake provides delivery_module when a
project source declares that dep.
scaffold.toml carries a top-level [modules] table keyed by module_name
with explicit flake and role (project | dependency) fields.
basecamp modules writes the table at capture time; dep resolution at
build time is a key lookup against that table. scaffold.toml is the
sole human-readable source of truth for the captured module set;
basecamp.state is reduced to pin-artefact metadata only. (Originally
nested under [basecamp.modules.*]; lifted to top level in schema 0.2.0
because modules are project-level Logos artifacts and basecamp is one
consumer of them, not their owner.)
Populating module_name on capture:
path:flake sources — read directly from the source'smetadata.json.name(the source tree is on the local filesystem).github:flake sources —metadata.jsonis only available after building, so scaffold derives a best-guess name from the repo slug (striplogos-prefix,-→_) and prints a one-line assumption note at capture time inviting the user to editscaffold.tomlif wrong.
The tradeoff: basecamp modules now writes to scaffold.toml, widening its
write surface beyond the derived basecamp.state. The upside is that the
captured module set becomes reviewable in version control and diff tooling
(a single TOML section, not a line-oriented state file), and dep-resolution
lookups become deterministic key matches — the "is this dep covered by
something I already captured?" question has an unambiguous answer.
Basecamp Pin Bumps Move as a Set
[repos.basecamp], [repos.lgpm], BASECAMP_DEPENDENCIES and
BASECAMP_PREINSTALLED_MODULES look like four independent knobs. They are one.
Scaffold installs modules by shelling out to the lgpm CLI it builds from
[repos.lgpm], and basecamp then scans and loads what landed in the profile
using logos-package-manager-module — which is built from the same
logos-package-manager source, at whatever rev the basecamp release locks. The
writer and the reader are two builds of one library. Letting the two pins drift
means the installer and the app disagree about package format, validation and
install layout, and the failure mode is a profile that comes up with zero
modules rather than an error at install time.
The rule, therefore: scaffold's default lgpm pin is the rev the default
basecamp pin locks, read out of that release's flake.lock rather than chosen
independently. Two corollaries follow from the same coupling:
- Companion pins are release-scoped too. The pinned
lgpmvalidates package structure and Merkle content hashes on install, so a companion module whose.lgxpredates that tooling stops installing.BASECAMP_DEPENDENCIESentries move with the pair. - The bundled-module list is read off the release. Which modules basecamp
ships itself changed between 0.1.x and 0.2.x (
counter,counter_qml,webview_appleft;package_downloaderarrived), and dep resolution skips exactly those names. A stale list either sendsbasecamp moduleshunting for a flake that does not exist, or captures a module basecamp already provides.
Rejected alternative: pinning lgpm to the last pre-validation rev so older
.lgx files keep installing. It buys nothing — the validating library is inside
the basecamp binary either way, so a package that fails validation at install
time would fail to load at runtime instead, with less signal. For the same
reason scaffold does not pass --allow-unsigned: silencing the installer's copy
of a check the app also performs converts a clear error into a mystery. install
turns the validation failure into a hint naming the rebuild instead.
Cost: a basecamp bump is never a one-line change, and the pins cannot be updated
by a dependency bot that treats each repo separately. That is the honest shape of
the dependency, and DOGFOODING.md re-opens the whole B series when any member
of the set changes.
Sibling --override-input Resolves By Declared Input Name
Multi-sub-flake projects rely on --override-input <input> path:<sibling-abs> so a sub-flake's path:../<sibling> inputs resolve to the developer's working tree instead of whatever github: ref is in its lock. The first implementation keyed overrides by the sibling directory name on disk — a convention where input names are expected to match directory names. Two problems:
- Projects with snake_case input names and kebab-case directory names (e.g.
inputs.tictactoe_solo_ai.url = "path:../logos-tictactoe-solo-ai") fail: nix emitsinput has an override for a non-existent input <dirname>, drops the override, falls through to the originalpath:..URL, and errors out under pure-eval. - The convention isn't visible at call sites: there's no error telling the developer to rename either the input or the directory; the break appears as a nix lock-resolution error from inside the store copy.
Scaffold now reads the target sub-flake's flake.nix at both probe time (basecamp modules) and build time (basecamp install / build-portable), looking for <name>.url = "path:../<sibling>" patterns, and emits --override-input <name> path:<sibling-abs> — keyed by the declared input name, not the directory name. Directory and input names no longer need to match.
The parser is line-level and recognizes the common single-line declarative forms (<name>.url = "…" and inputs.<name>.url = "…"). Multi-line nested-attrset forms (inputs.x = { url = "…"; flake = false; }; split across lines) are out of scope — they fall through silently and the probe or build may fail with the raw pure-eval error. Widening the parser is cheaper than adding a nix-based input enumerator, since the latter also hits pure-eval constraints on the very inputs we're trying to unstick.
The tradeoff: adding a minimal flake.nix text parser to scaffold. The upside: one less convention the user has to learn to get their project building, and the error mode goes away for legitimate non-convention projects. Build-time filtering via flake_declared_inputs (nix flake metadata) remains as defense-in-depth; after this change, the parser's output is already constrained to declared names, so the filter is redundant on the common path and only fires when the metadata fetch itself succeeded against some nix setup the parser didn't.
Flake Attribute Selection is a Resolver Parameter
install and build-portable share a single source-resolution routine that is
parameterized on the flake attribute name (lgx vs lgx-portable). Hardcoding two
parallel resolvers would duplicate the precedence rules (explicit → root flake →
sub-flakes → targeted failure) and bug-surface the sibling path-input override
logic. The tradeoff is one extra argument on an internal helper; the upside is
that any resolver fix automatically applies to both commands, and the "only
.#lgx-portable found" and "only .#lgx found" error paths are symmetric by
construction.
Vendoring spel Per-Project for Program ID Surfacing
deploy needs to print the deployed program's on-chain ID (the risc0 image
ID) so the user can hand it to a client without rerunning a separate tool.
Three options were considered:
- Compute the image ID in-process by depending on a risc0 crate
(
risc0-binfmt/risc0-zkvm). - Re-implement the SHA-256 + page-tree construction directly in scaffold.
- Shell out to
spel program-idand parse its output.
Option (1) ties scaffold's image-ID computation to a specific risc0 release; version skew with the user's project's risc0 dependency would silently produce wrong IDs. It also pulls a sizable risc0 dep tree into scaffold's own build. Option (2) is even more brittle — any upstream change to the construction silently produces wrong IDs.
Scaffold vendors spel per project the same way it vendors LEZ: clone
logos-co/spel into the project, pin a commit (DEFAULT_SPEL_PIN,
currently tag v0.2.0-rc.5), build it during setup, and invoke the
project-local binary from deploy. spel itself depends on the same
risc0 crate the user's project does, so the image ID it computes is
byte-for-byte the value the sequencer will use. The pin is a scaffold-
shipped default overridable via [repos.spel].pin in scaffold.toml;
doctor reports drift the same way it does for LEZ.
LEZ-version alignment. Spel itself vendors LEZ as a Cargo dependency
for its sequencer-RPC client and wallet helpers. Picking a spel pin
whose spel-cli/Cargo.toml references a different LEZ commit than
scaffold's own DEFAULT_LEZ_PIN is a latent footgun: spel and the
scaffold-built sequencer would speak different versions of the
sequencer protocol, breaking lgs spel -- ... subcommands that hit
the network. Image-ID computation is unaffected (it's purely guest-ELF
- risc0-zkvm), but other spel surfaces would silently diverge.
We pick DEFAULT_SPEL_PIN to satisfy this alignment at every bump
(currently v0.2.0-rc.5, which pins LEZ via tag = "v0.2.0-rc1" —
the same commit DEFAULT_LEZ_PIN resolves to). The counter-intuitive
naming — v0.2.0-rc.5 is newer than the unsuffixed v0.2.0 tag —
is upstream's choice and a maintenance hazard worth flagging when
revising the pin. doctor enforces alignment at runtime by reading
<spel.path>/spel-cli/Cargo.toml and warning if neither
DEFAULT_LEZ_PIN nor DEFAULT_LEZ_TAG appears in spel's dependency
declarations.
The image ID is computed locally from the submitted ELF — scaffold does not (yet) verify on-chain inclusion. LEZ wallet/RPC don't currently surface deploy receipts; once they do, scaffold can add an inclusion check after submission and the human-output caveat note can retire. Until then, the deploy summary explicitly distinguishes "wallet accepted" from "chain confirmed."
The tradeoff is that setup now does an extra multi-minute build on first
run and the deploy path forks a subprocess to recover a value that could
in principle be computed inline. The upside: scaffold has zero risc0 dep
of its own, the program ID always matches what the sequencer sees, and a
logos-scaffold spel -- <args> proxy falls out for free so users can run
any spel subcommand without a global install. extract_program_id is
bounded by a wall-clock timeout and falls back to a non-fatal "unavailable"
hint so a broken or hung spel can never gate a successful deploy.
Schema Migration via init (0.2.0 generalization)
When [repos.spel] was first added, every scaffolded project had a
config lacking it. The original solution: hard-fail in the parser with
a targeted error pointing at init, which appends the missing section
in place. Default-filling silently was rejected because the warning
would fire on every invocation forever (deploy, doctor, localnet,
wallet, spel, report all parse) and would corrupt
machine-parseable outputs (doctor --json, deploy --json).
The 0.2.0 schema migration generalizes that pattern. init is the
single migration entry point for any pre-0.2.0 scaffold.toml shape:
- pre-spel files (no
[repos.spel]) - legacy
urlfield on[repos.{lez,spel}] [basecamp].pin/.source/.lgpm_flake(moved to[repos.basecamp]/[repos.lgpm])[basecamp.modules.*](moved to top-level[modules.*])
init walks the file via toml_edit so comments, key ordering, and
unrelated sections survive the rewrite. It bumps [scaffold].version
to 0.2.0, prints a one-line summary of what changed, and refuses
already-migrated files. Pre-0.2.0 lgpm flake refs of the form
github:owner/repo/<sha>#attr are split into source/pin/attr
automatically; unparseable refs trigger a hand-edit hint with a
default pin written in place so the file is at least valid.
Every other command — deploy, doctor, setup, localnet,
wallet, spel, report, basecamp * — refuses pre-0.2.0 files
with a single line: "scaffold.toml uses an old schema (...). Run
logos-scaffold init to migrate to v0.2.0; existing settings are
preserved." JSON consumers stay clean.
scaffold.toml Schema: Three Namespaces
Schema version 0.2.0 ([scaffold].version = "0.2.0") factors the file
into three orthogonal namespaces:
[repos.<name>]— every pinned external git dependency. One schema for all four:lez,spel,basecamp,lgpm. Fields:source(clone URL or flake source),pin(git SHA), optionalbuild("cargo"default,"nix-flake"for nix-built deps), optionalattr(flake output attribute), optionalpathoverride. Adding a fifth dep is a one-section addition with no code dispatch on section name.[modules.<name>]— Logos modules the project ships.flakerole. Basecamp consumes them viabasecamp install/launch/build-portable, but they're not basecamp's property — a future test harness or alternative loader can pick them up the same way.
[<feature>]— runtime config per feature:[scaffold],[wallet],[framework],[localnet],[basecamp](port allocation only).
Earlier schemas (pre-0.2.0) mixed all three: [basecamp] carried
pin / source / lgpm_flake / modules together with port_base, and
[repos.{lez,spel}] carried both url and source. Migration is via
logos-scaffold init, which detects any old-shape signal (pre-0.2.0
version stamp, url field, [basecamp].pin/.source/.lgpm_flake,
[basecamp.modules.*]) and rewrites in place via toml_edit so
comments and key ordering survive.
Pin-Only Storage, Path Derived at Runtime
For every [repos.<name>], scaffold.toml stores pin and
source only; the on-disk clone location is derived at runtime from
resolve_cache_root() + "repos/<name>/" + pin. The file is therefore
portable across machines and CI by default — nothing in it depends on
the host's $HOME or cache layout. The cache_root chain
(env → [scaffold].cache_root → XDG default) decides where the clone
lands; the pin decides which commit is checked out.
[repos.lez] and [repos.spel] carry an additional optional path
field that [repos.basecamp] and [repos.lgpm] don't need. When set
it's authoritative — used literally if absolute, joined to
project.root if relative — and exists for two cases:
--vendor-depsprojects, wherenewwrites the relative literal.scaffold/repos/{lez,spel}so the clone lives inside the project tree.- Pre-portability
scaffold.tomlfiles in the wild that still carry an absolute cache path; the loader honors them verbatim so no migration is forced for that one field. Settingpath = ""and re-runningsetupopts back into portability.
setup decides sync mode from the same field: empty path →
cache-managed → auto-reclone on origin mismatch; non-empty path →
vendored or user-overridden → fail-on-mismatch so a developer's
checkout is never silently rewritten.
Build Dispatch
[repos.<name>].build is a discriminator with two values:
"cargo"(default; emitted only when the user opts into a non- default elsewhere).setupbuilds the clone viacargo build --release -p <hardcoded target>. Targets stay code-side; making them data-driven didn't earn its complexity."nix-flake".basecamp setupbuilds vianix build <source>/<pin>#<attr>.attris required.
The two paths share resolve_repo_path for the on-disk location and
source / pin storage; they diverge only in the build command they
issue.
Schema-Stale Files Are Hard-Failed
The parser refuses to load any pre-0.2.0 file. The error names the
specific stale shape (e.g. [basecamp].lgpm_flake (moved to [repos.lgpm])) and points at init. Re-running init migrates and
the user proceeds. Default-filling the missing fields with a stderr
warning was rejected because the warning would corrupt
machine-parseable outputs (doctor --json, deploy --json) and
because users would never get nudged hard enough to actually
migrate. Hard-fail + targeted hint is the same model as the original
[repos.spel] migration.
lgs run is a Single-Command Inner Loop
The dev's "change → build → deploy → interact" cycle was multiple commands
(build, localnet start, wallet topup, deploy, then ad-hoc post-deploy
calls). Scaffold could have stayed at primitives and let projects script the
sequence themselves; instead, lgs run collapses the sequence into one
command with numbered step headers and optional post-deploy hooks.
Tradeoff: scaffold takes on responsibility for the pipeline shape. Adding or
reordering steps becomes a coordinated change across cmd_run, cli args,
serializer, and the dogfooding doc. Upside: every project gets the same
discoverable inner-loop story without per-project shell-script drift, and
the env contract that hooks see is uniform across projects.
The pipeline composes the existing primitives (cmd_build_shortcut,
build_idl_for_current_project, cmd_localnet, cmd_wallet_topup_inner,
cmd_deploy) — no parallel implementation. Step ordering is fixed: a new
step joins the chain rather than being sequenced by configuration.
Individual steps may, however, expose a boolean skip — deploy = false
(step 5) and topup = false (step 4) both do. The boundary is ownership,
not convenience: a step becomes skippable only when the project can
legitimately own that concern itself (it deploys from a post_deploy hook;
its demo binary claims from the faucet at runtime), and never to work around
a scaffold bug. Three constraints keep the pipeline shape visible even when
a step is skipped:
- The step header still prints, in its own slot, saying it was skipped and
why —
total_stepsdoes not shrink and the remaining steps are not renumbered. - The skip defaults to not skipping, written as a hand-rolled
Defaultrather thanderive(Default), so an existing project's behavior cannot change by adding the field. - The decision is exported to hooks (below), so a project that took over a concern can tell whether scaffold acted on this run.
A step whose skip cannot satisfy all three does not get a knob.
Hook Env Contract is a Documented Public Surface
Post-deploy hooks run via sh -c with cwd at the project root. The env
they see is stable, documented in README, and validated by unit and
integration tests:
SEQUENCER_URL/NSSA_WALLET_HOME_DIR/LEE_WALLET_HOME_DIR/SCAFFOLD_PROJECT_ROOT/SCAFFOLD_IDL_DIR— pipeline state. The two wallet-home vars always carry the same path.SCAFFOLD_TOPUP_SKIPPED/SCAFFOLD_DEPLOY_SKIPPED— run-level outcome of the two skippable steps,1or0. Always set, never absent: a hook that had to treat "unset" as "scaffold did it" could not distinguish that from an older scaffold that cannot report. Each is produced by the same expression that executes or skips its step, so what a hook is told cannot drift from what ran. Both describe the run rather than a program;SCAFFOLD_TOPUP_SKIPPEDhas no per-program form at all, and the deploy one's suffixed spelling is not an independent per-program signal (below).SCAFFOLD_DEPLOY_SKIPPED_<name>— the run-levelSCAFFOLD_DEPLOY_SKIPPEDvalue repeated under each program's suffix, so the per-program family (SCAFFOLD_PROGRAMS/SCAFFOLD_PROGRAM_ID_<name>/SCAFFOLD_GUEST_BIN_<name>) stays parallel and a hook iterating$SCAFFOLD_PROGRAMSnever hits a missing var. Deploy is skipped for the whole invocation or none of it, so every program's copy carries the same value — do not read a per-program outcome into it.SCAFFOLD_PROGRAM_ID/SCAFFOLD_GUEST_BIN— single-program shortcuts. Set only when exactly one program is deployable; absent for multi-program projects so hooks fail loudly rather than silently picking up the wrong program.
The wallet home is exported under its upstream-wallet names rather than
under a SCAFFOLD_* prefix: hooks that exec the wallet binary (directly
or via cargo run --bin run_*) need the var under the name the binary's
WalletCore::from_env() reads. Renaming for hook ergonomics would
silently break those hooks. That reasoning is unchanged — but upstream
now has two such names. LEZ v0.2.0 renamed NSSA_WALLET_HOME_DIR to
LEE_WALLET_HOME_DIR, and a v0.2.0 wallet that sees only the old name
does not error: it falls back to ~/.lee/wallet. Scaffold therefore
sets both names, to the same path, on every wallet subprocess that
touches the wallet home and on every hook — the exception is doctor's
wallet --version probe, which only reads the binary's version string.
The list lives in WALLET_HOME_ENV_VARS (constants.rs); wallet
subprocesses apply it through set_wallet_home_env. Follow that
pattern for any future rename: add the new name, keep the old one,
never swap.
The single-program metadata is resolved once per lgs run invocation
and reused across every hook so multiple hooks don't multiply the cost
of spel program-id.
Build Output Discovery
The deploy command must work for any scaffolded project regardless of its name.
Binary discovery should derive paths from the actual project structure, not assume the template's naming.
The implementation walks both target/riscv-guest/ (the canonical risc0 layout used by the
scaffold template) and methods/target/ (the sub-crate workspace layout), matching <program>.bin
files whose path components include both a riscv32im* target triple and a release directory.
Release builds are preferred; if only a debug build exists, that is used as a fallback. When
multiple matches exist, the shallowest path wins. This keeps the scaffold general and avoids
coupling to a specific project name or workspace layout.
Guest Build Discovery
The deploy-side discovery above only works if guest binaries actually exist on disk. Risc0
guest crates target riscv32im-risc0-zkvm-elf and are intentionally excluded from the parent
workspace in real projects (the scaffold template lists them as members for ergonomics, but
projects that grew their own workspace — for example to add unrelated host crates — typically
exclude methods and methods/guest so cargo build --workspace doesn't try to build them
for the host triple).
build therefore probes for methods/Cargo.toml after the workspace build and, when present,
runs cargo build --release --manifest-path methods/Cargo.toml so the guest crate compiles
regardless of whether it is a workspace member. The probe is a single stat; absence is a no-op,
so non-Risc0 projects pay nothing. Release mode is chosen so the produced .bin lands in the
same release/ path component the deploy-side discovery requires — the two halves are designed
together. The shared methods directory name lives in crate::constants::METHODS_DIR.