Skip to content

Build the first executable scaffold: workspace, Tauri window, runtime, CLI socket, CI - #5

Merged
tonite31 merged 17 commits into
mainfrom
work/w-wxar2-scaffold
Aug 11, 2026
Merged

Build the first executable scaffold: workspace, Tauri window, runtime, CLI socket, CI#5
tonite31 merged 17 commits into
mainfrom
work/w-wxar2-scaffold

Conversation

@tonite31

Copy link
Copy Markdown
Contributor

Implements docs/FOUNDATION_DESIGN.md section 14 — the seven scaffold items, and nothing beyond them. Work unit w-wxar2.

What this adds

  1. pnpm/Cargo workspacecrates/trdr-core (pure domain, held to a five-crate dependency allowlist by ci/check-core-deps.sh), crates/trdr-runtime, crates/trdr-cli (bin trdr), apps/desktop.
  2. Tauri main window with an explicit capability file — exactly two permissions (allow-ping, allow-bootstrap-get), no core: grants, zero plugins linked. build.rs creates the AppManifest without which Tauri would skip ACL for app commands entirely; a canary test proves core:default members stay unreachable.
  3. React AppShell — Today/Lab/Strategies on a hash router; the terminal host sits beside the outlet in a layout route and a node-identity test proves it survives navigation.
  4. trdr-core contracts — ULID workspace id, versioned error envelope (codes + named params, no free-text field), UI command/query types with specta derives; TypeScript bindings generated via tauri-specta with a drift check.
  5. Bundled SQLite (3.53.2), migration 0, writer lease — WAL, STRICT bookkeeping, section-6.1 open sequence. The lease is an flock on an open descriptor; exclusion is proven by a child-process test and lease-before-DB is type-enforced (compile_fail doctest).
  6. trdr app status over the Unix socket — newline-delimited JSON, peer-uid check, 104-byte sun_path guard, 5s timeouts; round-tripped through the real CLI binary in tests.
  7. CI + one synthetic fixture — macos-latest; fmt on pinned nightly-2026-08-10 (Allman), clippy/test on stable; frontend build ordered before cargo because generate_context! embeds dist; fixture sha256 recomputed by its consuming test.

Stop condition — proven live on this Mac

  • First run created ~/.trdr/workspaces/default with workspace.json; trdr app status answered over the socket with the same workspace, lease held, schema version 1.
  • A second instance was refused with a typed DB_BUSY envelope before any window; the first kept serving. After SIGKILL the next launch cleared the stale socket under the lease.
  • The release WebView round-tripped typed ping and bootstrap.get under the two-permission capability (instrumented run: both fired; visual check: tabs and status line render).

Reviewer notes (flagged by the builders)

  • Section 12's error-code set has no start-up family, so start-up failures map onto existing codes with a reason param (lease held → DB_BUSY) — worth a deliberate look.
  • bootstrap.get tells the screen its workspace path; section 9.1 forbids the reverse direction (screen passing a path). Deliberate.
  • Three upstream defects are worked around and pinned by tests: tauri-specta unbound generic at command return position, specta refusing i64, tauri build packaging the wrong binary without mainBinaryName.
  • Deferred with typed refusals, by design: pre-migration recovery points (backup unit), real KIS/collector/backtest work (later units per decision 01kznp0ybajp3dqb1rb8qbk5wy).

🤖 Generated with Claude Code

tonite31 and others added 17 commits August 11, 2026 15:12
The workspace is three crates with one direction of dependency: the CLI and
(later) the Tauri host go through trdr-runtime, trdr-runtime goes to trdr-core,
and nothing goes back. Members are listed one by one rather than globbed so the
src-tauri crate a later track adds cannot join by accident, and the spikes are
excluded so they keep resolving against their own versions.

trdr-core carries what FOUNDATION_DESIGN sections 5, 9, and 12 fix:

- Identity is a ULID. Parsing rejects the two 26-character strings the codec
  would otherwise accept and print back differently, so an identifier survives
  its own round trip.
- The error envelope has a code and named parameters and no message field at
  all, so no sentence can carry an upstream body or a credential. AUTH_INVALID
  and UPSTREAM_UNAVAILABLE stay separate codes, with room beside them for what
  the upstream itself said.
- Both IPC contracts are typed. Socket frames parse only through
  from_json_line, which checks the version before the method and answers every
  refusal with a FrameError variant rather than a string.

trdr-runtime is empty on purpose: five named modules, one per later track.
trdr-cli answers app status with the envelope any surface would get, since with
no socket built there is genuinely no app to reach.

Formatting needs nightly rustfmt, which is the only place nightly is used;
clippy, tests, and builds are stable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apps/desktop is a plain Vite, React, and TypeScript build with an empty App
component. No Tauri: the composition root, the window, and the capability file
belong to their own track, and putting a src-tauri directory here now would
mean guessing at the capability list before there are commands to enable.

packages/* is in the workspace globs before the directory exists, so the UI kit
track only has to create its folder.

pnpm 10 skips a dependency's install scripts unless it is listed, and esbuild
needs its to place the binary Vite calls, so it is listed. The lockfile is
committed and pnpm install --frozen-lockfile is clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The specta derives compiling is not the same as the types being exportable, so
there is now a test that asks specta to describe them. It caught the identifier
types: delegating to String produced a named reference, which would have
generated a `String` alias in TypeScript rather than `string`. They delegate to
str instead.

Alongside it, three boundaries that were asserted in prose and are now asserted
in tests: a command that takes no parameters refuses them, a field beside the
method is refused rather than dropped, and a path with a newline in it is
escaped rather than allowed to end a frame early.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Section 14 of the foundation design asks the first scaffold for one
synthetic fixture. This is it: a single ingest bundle in the shape
section 8.1 fixes, with a bundle.json manifest beside two NDJSON record
files, and an integration test that consumes it.

Nothing in the bundle was observed anywhere. The market is SYN, the
instruments are SYN0001 and SYN0002, the upstream host sits under the
.invalid domain RFC 2606 reserves so it can never resolve, and the
manifest declares its use basis as synthetic. Every price and volume
follows a closed-form rule stated in the test, so the file can be
regenerated from its own description. One record carries revision 2 of a
bar already present at revision 1, so the fixture exercises the
append-a-new-revision rule rather than only the happy path.

Section 5.2 requires every persisted object to carry a SHA-256 and a
source manifest, and a manifest whose hashes nobody recomputes is
decoration. So the test reads the bytes off disk and recomputes the
digest of every record file. The SHA-256 it uses is local to the test
file and proven against the FIPS 180-4 vectors, which keeps this
verification from costing trdr-core a dependency of any kind — not even
a dev-dependency, in the one crate whose dependency list is meant to be
auditable at a glance.

Timestamps are written in the canonical UTC spelling that
trdr_core::Timestamp accepts. The design's section 8.1 example writes
one available_at as +09:00; that is a second spelling of an instant that
already has one, and section 7.2 asks for exactly one, so the fixture
follows the type rather than the example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
crates/trdr-core is the domain crate, and its Cargo.toml says in a
comment that it must never gain Tauri, tokio, rusqlite, reqwest, or
anything else that touches the filesystem, the network, the clock, or
the OS. A comment is not a gate, and the crate everything else depends
on is exactly the one where an accidental I/O dependency would be least
visible in review.

The script compares the crate's normal dependency tree against the five
crates it is allowed and fails with what to do about each direction of
drift: move the code that needs the new crate to trdr-runtime, or, if
the boundary really should grow, get that agreed and widen the allowlist
in the same commit that adds the dependency.

Dev- and build-dependencies are deliberately out of scope, which is what
--edges normal buys: they never ship, so they cannot leak I/O into a
release. --locked means a lockfile that disagrees with the manifests
fails here too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repository's first workflow, and the last item section 14 asks of
the first scaffold. It formats with the nightly rustfmt the repository's
brace style requires, lints and tests on stable, builds the desktop app
with the pnpm the packageManager field pins, and checks trdr-core's
dependency boundary.

It runs on macOS because that is the only platform trdr runs on. The
file locking, the peer-uid check on the CLI socket, and every line of
Tauri and AppKit code exist for Darwin alone, so a green Linux run would
be evidence about software this product never ships. The runner costs
ten times a Linux minute on a private repository, which is why the job
cancels superseded runs, caches both toolchains, and carries a timeout
far below the six-hour default.

Two choices worth naming. The nightly is pinned to a date: nightly
rustfmt changes its own output between builds, and a formatting failure
should always mean someone's code changed, never that the toolchain
drifted underneath it. The comment beside the pin says how to format
locally the way the job checks. And the desktop app is built before
cargo runs, because the Tauri crate landing as apps/desktop/src-tauri
embeds the built frontend at compile time — without dist on disk, clippy
and the tests would fail on a missing directory rather than on anything
real.

Everything is expressed with --workspace and --filter rather than lists
of packages, so the crates and packages arriving from parallel tracks
are covered the day they land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Section 14 item 5. The product root, the lease that makes one process the
writer, and migration 0.

The lease is an `flock` held on an open descriptor at
`<product root>/run/writer.lock`, never a pid in a file: a pid cannot answer
whether its holder is still alive, because after a crash it is either stale or
has been reused, and the file reads the same either way. The kernel drops an
`flock` when the descriptor closes, and a process that dies closes every
descriptor it had. `tests/writer_lease.rs` proves it with a real child process,
which is the only honest proof — `flock` belongs to an open file description
rather than to a process, so a second open inside one process, or a thread,
would show nothing.

Section 6's order is enforced by the types rather than remembered. Taking the
lease produces a `WriterLease`, `Database::open` takes one, and there is no
other constructor, so opening the database without being the writer is not
something a caller can express.

SQLite is compiled in rather than borrowed from macOS. Section 6 asks for
3.51.3 or newer; `rusqlite` 0.40 bundles 3.53.2, `Database::open` refuses
anything older, and a test asserts it so a dependency bump cannot walk the
version backwards quietly. WAL, foreign keys, a busy timeout, and a startup
`quick_check` are all on, and migration 0 creates the `schema_migrations`
ledger section 6.1 describes, records its own SHA-256, and re-runs as a no-op.

Every path hangs off an injected `ProductRoot`, and the scratch roots in
`test_support` live under `/private/tmp` — short enough for a `sockaddr_un`
path, and never inside a person's home directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Section 14 item 6. The socket server the CLI talks to, and the CLI end of the
round trip.

Which socket file is stale is decided by the writer lease and by nothing else.
Whoever holds the lease is the only live app, so a socket file it finds at bind
time was left by one that died, and unlinking it is safe. Probing the socket
instead is the usual approach and is wrong in both directions: an app that is
alive but wedged answers nothing and would be declared dead, and a path whose
inode was replaced answers something else. `SocketServer::bind` takes the lease,
so the unlink is not reachable without it.

Who may speak is checked twice. The socket is `0600` inside a `0700` directory,
and every accepted connection is asked for its peer's uid through `getpeereid`;
the first is a property of a path, the second is what the kernel recorded and
cannot be restated by the caller. A connection from another uid is closed
without an answer.

Framing is written out rather than taken from `BufRead::lines`, because `lines`
grows until it meets a newline and a peer that never sends one would be a memory
cost this process pays. The reader refuses at `MAX_FRAME_BYTES` while the bytes
are still arriving, and it survives both halves of the problem — one frame split
across reads, and several frames in one. A caller that connects and says nothing
is let go after `REQUEST_READ_TIMEOUT` rather than held.

`trdr app status` now connects, sends the frame, and prints the typed answer.
With nothing listening it keeps the `APP_NOT_RUNNING` envelope stage 0
established: exit 1, and JSON on standard output under `--format json`. The
integration test runs the built binary as a real process against a real socket.

`trdr-core` gains one type: `AppStatusResult`, the result `app.status` returns.
The method itself was already in `SocketMethod`. Like everything else in that
file it carries no `specta` derives, because both ends are Rust.

`AppBridge` is the seam the Tauri app will implement when it lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Tauri composition root joins the workspace as `apps/desktop/src-tauri`,
with the settled bundle identifier `com.fxylabs.trdr` and the main window
`tauri.conf.json` declares.

What the WebView may call is the point of this commit. `build.rs` declares the
app's own commands to Tauri's access-control list, and that declaration is what
makes application commands subject to the list at all: without an application
manifest Tauri treats every `#[tauri::command]` as callable from any WebView,
and a capability file only ever describes plugins. With it,
`capabilities/main.json` is the whole answer, and it grants two permissions —
`allow-ping` and `allow-bootstrap-get`. There is no `core:default`, so the
screen gets no window geometry, no path resolution, no event listening, no
WebView enumeration, and no bundle identifier; and no Tauri plugin is linked, so
there is no filesystem, shell, SQL, dialog, or HTTP command to expose in the
first place. The content security policy starts at `default-src 'none'` and
names what it allows, with a separate looser policy for the Vite dev server so
that looseness cannot reach a packaged build.

`tests/capability.rs` builds the app from the shipped config and pushes messages
through the real IPC path, so what it proves is what the access-control list
resolves to rather than what the file looks like. Its denial list is drawn
entirely from `core:default` and was checked by granting `core:default` and
watching every entry turn red. `tests/config.rs` holds the settings Tauri and
Vite each own half of — the dev port, the dist directory, the identifier, the
policy — because when those two disagree the symptom is a blank window rather
than a failed build.

The two handlers are stubs by design: no database, no workspace discovery, no
socket, and nothing that creates `~/.trdr`. They answer with
`trdr-core`'s versioned response envelope, infallibly, so that a failure is the
`error` arm of the envelope rather than a rejected promise competing with it.

The icon is a placeholder; Tauri needs one to compile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tauri-specta turns the registered commands into both Tauri's dispatch table and
the TypeScript the React app imports, from one list, so the two cannot describe
different surfaces. The generated file is committed, which keeps the frontend
build a plain `vite build` with no Rust toolchain in it, and costs a file that
can silently describe a signature the host no longer has. `tests/bindings.rs`
pays that cost: it regenerates and fails on any difference, naming
`cargo run -p trdr-desktop --bin export-bindings` as the fix.

Two things the generator does had to be dealt with rather than accepted.

tauri-specta 2.0.0-rc.25 drops the concrete type arguments when it splits a
generic return type into serialize and deserialize forms, and emits
`UiResponseEnvelope_Serialize<T>` with `T` bound to nothing — TypeScript that
does not compile. The same instantiation in field position substitutes
correctly, so each command now returns a `#[serde(transparent)]` newtype that
puts the generic one level down. Transparent means the bytes are unchanged, and
a test holds that, so the workaround can be deleted upstream-first without
touching what the WebView receives. A second test fails on any unbound
parameter in a command signature, so this cannot come back quietly.

specta refuses to export `i64` at all, because `JSON.parse` truncates past 2^53.
One `i64` crosses here — the counts and limits in `ErrorParam::Integer` — and it
already travels as a JSON number, so `number` describes what arrives and
`bigint` would not. The override is set at this boundary rather than in
`trdr-core`, which has no business knowing about TypeScript, and the exported
type list is pinned by a test so the next 64-bit field cannot inherit the
decision silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Today, Lab, and Strategies are children of a layout route, and the terminal host
is a sibling of that layout's outlet rather than anything a screen renders. That
nesting is the whole feature. A terminal is not a view of state that can be
thrown away and rebuilt — it is a live process with scrollback, a cursor, and a
program mid-sentence — so the node it will attach to has to be created once and
navigated around, not recreated per screen.

React Router earns its place on that one feature: a parent route with an
`<Outlet/>` is what keeps an element mounted while its children swap, and a
router without nesting cannot give that without a portal. Hash mode, because a
packaged build loads from `tauri://` and asks that protocol for whatever path is
in the URL — there is no server behind it to fall back to `index.html`, so a
reload on `/lab` would ask for a file that was never built. The fragment is
never requested, and the URL stays real, which the `trdr` CLI's `ui.open` will
want later.

`AppShell.test.tsx` asserts node identity across every navigation, not presence.
A host that unmounted and remounted would still be findable by id and would
still have lost whatever was attached to it, so the test attaches something from
outside React — the way xterm will — and checks it survives the round trip. That
this is not vacuous was checked by moving the host into a screen and watching it
fail.

The screens are placeholders that name the section 9.1 command each is waiting
on. The stylesheet is the minimum that makes the three regions visible, with a
few colours borrowed from `design/ui-kit` so the placeholder is at least not a
different palette from the one that lands; applying the kit properly is a later
unit with visual review attached.

Vite's dev port is pinned and strict, because Tauri points the window at a fixed
address and a silent move to the next free port leaves it looking at nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`tauri build` succeeded and reported "Built application at:
target/release/export-bindings". The crate has two binaries and the CLI took the
wrong one, quietly: exit code zero, one line of build output naming a file
nobody reads. `bundle.active` is false today, so the consequence was cosmetic —
switch bundling on and the shipped `.app` would have contained a program that
writes TypeScript to a path from this machine and exits.

`mainBinaryName` says which one, and a test pins it.

Also closes the last of the four places a command has to be listed. tauri-build
already refuses a capability permission it did not generate, and `tests/config.rs`
already holds the capability file against `commands::COMMANDS`; what nothing
checked was the list `collect_commands!` actually registers. A handler added to
`COMMANDS` and forgotten in `builder.rs` would have left a permission granted for
a command that does not exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`workspace.json` is the one thing section 5.1 asks a first run to write that
track B could not: its two fields are an instant and a random identifier, and
`trdr-core` is built so that it can hold neither. So the two seams section 13
names arrive here as traits with one real implementation each, `test_support`
gains the stand-ins that make a manifest an assertable set of bytes, and
`workspace::Workspace` creates or reads the default workspace under the writer
lease.

The calendar arithmetic is written out rather than pulled in. It is twenty
lines with no locale, no zone database, and no configuration behind it, and
every day it can produce between 1969 and 2100 is walked in a test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app now does the four things section 14's stop condition asks for, in the
one order that is safe: resolve the product root, take the writer lease, open
the database and run migration 0, create `workspace.json` on a first run, and
bind the socket the CLI talks to. Each step produces the value the next one
needs, so the order cannot be written down wrong.

All of it happens before `tauri::Builder::run`, which decides what a second
copy does: the lease refuses it at step two, and it exits with a sentence and
an error envelope on standard error rather than putting up a window that
cannot write. `RunEvent::Exit` stops the socket explicitly, because the event
loop underneath Tauri ends the process on some paths and a destructor that
never runs is a socket file that outlives its app.

`bootstrap.get` answers with the workspace that was opened rather than with
constants, and reads it out of managed state instead of going to look — so no
command can open a second database, and the capability tests still run without
a product root. `tests/startup.rs` runs the real thing against a scratch root
and holds the guard that `~/.trdr` is named in exactly one file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shell asks the host two things when it mounts — `ping`, which proves the
bridge carries a validated type, and `bootstrap.get`, which answers with the
workspace the host opened — and renders the workspace id and the app version
from the response. A status line rendering a constant would look the same, so
the test asserts the value the mocked host returned rather than its presence.

The request id is minted here, in about thirty lines, rather than dropped and
minted by the host. `UiCommandEnvelope` makes the id the caller's, and it has
to be: it is what lets the side that is waiting name what it is waiting for,
and section 9.2 keys a repeat on it. Dropping the parameter would also delete
the boundary this scaffold exists to prove — `ping` takes a `RequestId`, not a
string, so anything but 26 characters of Crockford base32 is refused before the
handler is entered, and that is only a refusal of something if a caller
supplies one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tonite31
tonite31 merged commit 0439d81 into main Aug 11, 2026
1 of 2 checks passed
@tonite31
tonite31 deleted the work/w-wxar2-scaffold branch August 11, 2026 08:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant