Skip to content

Track: bynk as the front door — one owner for the project model #645

Description

@accuser

The theme

design/bynk-tooling-roadmap.md §5.1 is named "The bynk driver & the project-lifecycle arc", and ADR 0083 set its posture in one sentence: "a thin orchestrator over bynkc and the Node toolchain — bynk is to bynkc what cargo is to rustc. The compiler stays pure (compile / check / fmt / test)."

The arc shipped — doctor (v0.46), dev (v0.57), new (v0.58), deploy (slices through v0.172, spine #558). But it grew around the compiler rather than above it, and the analogy the ADR asserts does not hold today. The reason is structural, not cosmetic: rustc's unit of work is one crate, and cargo exists because rustc deliberately cannot see past it — someone has to own the graph, resolve it, and hand rustc resolved flags. compile_project takes a project root and compiles the whole thing, so there is no graph left for bynk to own. It filled instead with verbs borrowed from elsewhere: new (cargo), doctor (rustup), dev/deploy (wrangler) — while the compiler kept the project model.

ADR 0083 defined the compiler's purity as a verb list. The claim this track makes is that purity should have meant not knowing what a project is. bynkc discovers projects and reads bynk.toml's [paths] (bynk-driver/src/lib.rs:23-27bynk-emit/src/project/paths.rs:107), which puts it at the same layer as the driver. Verb overlap is not the problem and never was — cargo fmt and rustfmt both format, and nobody is confused, because the layering is unambiguous. Same-layer duplication is the problem.

Three things have also drifted from the ADR's own text since v0.46: bynk no longer shells bynkc (it links the pipeline in-process, crate-decomposition slice 7); the two share a front-end crate (#521, bynk-driver); and bynk grew mirrors of check/fmt/test (#487, v0.138) that site/src/content/docs/docs/bynk-cli.md:12-17 pins as "drop-in equivalent". That promise is a ceiling: every improvement to bynk's interface becomes a bynkc-compat problem, so the front door can never be more human than the compiler is.

The evidence that nobody owns the manifest. bynk.toml has three divergent readings today, none of them the front door's:

  1. [paths], read at the bottombynk-emit/src/project/paths.rs:107 (read_project_paths), below both binaries, reached via bynk_driver::project_options (bynk-driver/src/lib.rs:23).
  2. [paths], read differently at the sidebynk-lsp/src/project.rs:187-194 reduces the manifest to one src_dir (first include only; exclude explicitly ignored). This is gap 1 of the LSP-foundations track (Track: LSP foundations — the project model, the freshness contract, and the lifecycle under the shipped surface #640): "The LSP analyses a different project from the compiler."
  3. [fmt], read by neither CLIbynk_driver::run_fmt hardcodes FormatOptions::default() (bynk-driver/src/lib.rs:134), and both bynk fmt (bynk/src/fmt.rs:31) and bynkc fmt (bynkc/src/main.rs:492) route through it. Only the LSP reads [fmt]. So a project setting indent = "spaces" gets spaces on format-on-save and tabs from bynk fmt, while site/src/content/docs/docs/manifest.md:85 states the section is "consumed by bynkc fmt". That is a live defect and a false doc claim.

And a fourth key is inert by documentation: [paths] out"Consumed by the LSP; the compiler takes its output directory from the CLI, so this key does not override bynkc" (manifest.md:63). A manifest that declares an output directory no build honours.

This is not a formatter bug or an LSP bug. It is what happens when the tool that owns the user's intent is not the tool that reads the user's config — because the front door has no seat at the manifest. bynk has no build at all: to produce an artefact you type bynkc compile -o out --target workers. Nobody has ever typed rustc to build their project.

End state at retirement: bynk is the binary a human types, and it owns the project model — the manifest is its private input, bynk build needs no flags, profiles are named rather than inferred from argv, and the on-ramp teaches bynk throughout. bynkc keeps its verbs and loses its guesses: it is told its roots and does exactly what it is told, which is what makes it a compiler rather than a second driver. The rule that decides every future command: bynk infers, bynkc is told.

  • Realises: design/bynk-tooling-roadmap.md §5.1 (the driver arc, whose orchestration half is complete and whose front-door half was never built), and ADR 0083 by name — this track revisits its "the compiler stays pure (compile / check / fmt / test)" clause, which defined purity as a verb list and left the project model in the compiler. It adds no language surface: no grammar, AST, checker, or emitter semantics change.
  • Track doc (added by the settling PR): design/tracks/front-door.md

Why a track (the ADR 0076 trigger)

  • Multi-increment — a new verb (bynk build), a named-profile model, a crate extraction with a publish/release-plumbing row, a breaking change to bynkc's CLI, an on-ramp rewrite (install + tutorial 01 + the /docs/cli/ URL), and a human-facing output pass. No single delete-on-merge proposal carries that, and the subtractive slices are only safe once the additive ones have landed.
  • Surface not yet settled — the questions below are genuinely open, and two of them (where the manifest parser lives; whether bynkc keeps discovery) are hard to reverse once other code depends on the answer.
  • Security/safety boundary — none. Driver-side only; no language surface, and nothing outward-facing beyond what deploy already gates under its own track.

Two triggers hold, which is the bar.

Open design questions

  1. Where does the manifest parser live, and is the boundary structural or documentary? The proposal on the table is a new bynk-manifest leaf that bynk and bynk-lsp depend on and bynkc does not — making "the compiler cannot read the manifest" a compile error rather than a doc comment to maintain. It cannot live in bynk itself (a binary crate the LSP cannot depend on). Cost: a new published crate — seed-first publish through release-bootstrap.yml before trusted publishing, plus a row in both release workflows' hardcoded crate lists. The alternative is leaving read_project_paths in bynk-emit and enforcing the layering by policy, which is what we do now and is how it drifted.

  2. Interaction with the LSP-foundations track (Track: LSP foundations — the project model, the freshness contract, and the lifecycle under the shipped surface #640) — the sequencing question this track cannot answer alone. Track: LSP foundations — the project model, the freshness contract, and the lifecycle under the shipped surface #640's gap 1 is this same problem from the LSP side, and its fix could land as "make the LSP call read_project_paths", which would cement the reader at the bottom of the stack and make this track's slice 3 a rewrite rather than a deletion. If bynk-manifest lands first, Track: LSP foundations — the project model, the freshness contract, and the lifecycle under the shipped surface #640's gap 1 is solved by construction — both consumers read one parser. These two tracks must agree on the manifest's home before either ships its project-model slice. Settling should close this jointly, not in parallel.

  3. Does bynkc keep conventional-roots discovery? Dropping [paths] still leaves the question of whether bynkc check . may infer src/ when no manifest is present. Keeping it is convenient and is the slippery slope straight back to discovery; dropping it breaks muscle memory and any CI script (pre-1.0, but examples/ are compile-checked and the drift guards will surface it).

  4. Profile naming, and which is the default. The profile is currently a side-effect of which verb you typed: bynkc test sets .contracts(true) (bynk-emit/src/project.rs:354, per DECISION J of v0.115), bynkc compile leaves it false (:317), and --inspect silently switches import extension to .ts (bynkc/src/main.rs:116). That is three implicit profiles inferred from argv. Naming them raises a real default question: cargo defaults to dev and opts into --release, but bynk build's natural ancestor (bynkc compile) is contracts-off, i.e. release. Picking cargo's default would change behaviour; picking the ancestor's would invert cargo's ergonomics.

  5. Does bynk test stop shelling? It is the only verb that still shells bynkc (bynk/src/cli.rs:192); everything else links in-process since slice 7. That single exception is what keeps the resolution ladder, the Skew machinery (bynk/src/compiler.rs), and the "Which bynkc?" section (bynk-cli.md:302) user-visible. If the front door is the front door, that question should never reach a human and BYNK_BYNKC becomes an internal escape hatch. Note vscode-bynk compiler resolution is narrower than the bynk driver's — a driver-first install breaks the Test Explorer #486 already reports the resolution split biting the VS Code Test Explorer.

  6. The on-ramp inversion, and what it costs. site/src/content/docs/book/introduction/install.md:22 says cargo install --path bynkc, then invokes bynk doctor eleven lines later — a binary it never told you to install — and lists "bynkc exposes four commands" as the toolchain surface. book/tutorials/01-first-program.mdx:44 teaches bynkc compile demo.bynk --output demo.ts in the body with bynk new in a blockquote aside. /docs/cli/ is bynkc's page; bynk got the qualified /docs/bynk-cli/. Inverting the URLs is not free — the doc drift guards hardcode page paths, so it is a Rust-test change too.

  7. What does [build] contain, and does it subsume out? Candidates: target, platform, emit, out. [paths] out already exists and is inert; a [build] out would either move it (breaking) or shadow it (confusing). Interacts with N:M context→Worker deployment grouping in bynk.toml #551 (N:M context→Worker grouping in bynk.toml), which proposes its own manifest surface.

Candidate slice decomposition

Deliberately additive-first: every slice up to the last only adds a reader at the top, so nothing below changes and each is shippable alone. The single subtractive slice lands last, once nothing depends on the old path. bynk already has toml as a dependency (bynk/Cargo.toml), so slice 0 needs no refactor beneath it.

  • Slice 0 — bynk build. The missing verb, no required flags. Reads [build]/out in bynk, passes explicit CompileOptions down — CompileOptions::split(root, paths) (bynk-emit/src/project.rs:328) already takes them, so nothing below moves. Makes out live. Ships alone; the first brick of the boundary.
  • Slice 1 — named profiles. Replace the three argv-inferred profiles with a named model across build/test/dev/deploy. Makes --inspect's "compiles a debug build" stop being a footnote.
  • Slice 2 — the bynk-manifest leaf. One parser, consumed by bynk and bynk-lsp, not by bynkc. Honours [fmt] from the CLI (fixing the format-on-save divergence) and gives Track: LSP foundations — the project model, the freshness contract, and the lifecycle under the shipped surface #640's gap 1 its structural fix. Gated on Q1 + Q2.
  • Slice 3 — bynkc is told. The subtractive one: remove read_project_paths from bynk-emit, make bynkc take explicit roots. Gated on Q3.
  • Slice 4 — the on-ramp inversion. install/tutorial/URL, and retiring "drop-in equivalent" as a stated goal. Gated on Q6.
  • Slice 5 — human-facing output. bynk check prints nothing on success today; the cargo Compiling/Finished … in 0.5s cadence has more to say here, not less, because builds and deploys are per-context.

Slice status

  • Slice 0 — bynk build
  • Slice 1 — named profiles
  • Slice 2 — the bynk-manifest leaf
  • Slice 3 — bynkc is told (subtractive)
  • Slice 4 — the on-ramp inversion
  • Slice 5 — human-facing output

Front-loaded ADR candidates

Numbers taken at merge, not here.

  • Manifest ownership — who reads bynk.toml, which sections are the driver's private input versus a tool's own settings, and the crate-graph edge that enforces it. Hard to reverse: once bynk-lsp and bynk depend on a parser, moving it again is a breaking change across three published crates. Must be settled jointly with Track: LSP foundations — the project model, the freshness contract, and the lifecycle under the shipped surface #640.
  • The bynk/bynkc contract — "bynk infers, bynkc is told", superseding ADR 0083's verb-list definition of compiler purity, and recording that verb overlap is not the boundary (the cargo fmt/rustfmt precedent). This is the ADR that stops the drift recurring, and it should land with or before slice 0 — otherwise slice 0 is just a sixth verb.
  • The profile model — names, the default, and which verb selects which. Hard to reverse once [build] is documented.

Threat model

None, because the security/safety trigger is not ticked. This track is driver-side and adds no language surface; it creates no resources, handles no credentials, and touches nothing outward-facing. deploy's irreversible side effects stay behind their own gate under track #558, and this track does not change them. Slice 3 is breaking (a CLI contract change) but not unsafe; slice 2's [fmt] fix will reformat files in projects that set the section, which is a migration note rather than a threat.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions