Build the first executable scaffold: workspace, Tauri window, runtime, CLI socket, CI - #5
Merged
Conversation
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>
# Conflicts: # Cargo.lock
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements docs/FOUNDATION_DESIGN.md section 14 — the seven scaffold items, and nothing beyond them. Work unit w-wxar2.
What this adds
crates/trdr-core(pure domain, held to a five-crate dependency allowlist byci/check-core-deps.sh),crates/trdr-runtime,crates/trdr-cli(bintrdr),apps/desktop.allow-ping,allow-bootstrap-get), nocore:grants, zero plugins linked.build.rscreates the AppManifest without which Tauri would skip ACL for app commands entirely; a canary test provescore:defaultmembers stay unreachable.trdr app statusover 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.generate_context!embeds dist; fixture sha256 recomputed by its consuming test.Stop condition — proven live on this Mac
~/.trdr/workspaces/defaultwithworkspace.json;trdr app statusanswered over the socket with the same workspace, lease held, schema version 1.DB_BUSYenvelope before any window; the first kept serving. After SIGKILL the next launch cleared the stale socket under the lease.pingandbootstrap.getunder the two-permission capability (instrumented run: both fired; visual check: tabs and status line render).Reviewer notes (flagged by the builders)
reasonparam (lease held →DB_BUSY) — worth a deliberate look.bootstrap.gettells the screen its workspace path; section 9.1 forbids the reverse direction (screen passing a path). Deliberate.i64,tauri buildpackaging the wrong binary withoutmainBinaryName.🤖 Generated with Claude Code