AvelonDocs

Guide 02

Decisions

02. Decisions

Every architectural decision with the runner-up and why it lost. The runner-up is recorded so nobody relitigates it from scratch six weeks in.

Changing a decision here means updating the doc it governs. Do not change one silently in code.


Language and runtime

D1. TypeScript only. strict plus noUncheckedIndexedAccess. No JavaScript escape hatch, no any in public surfaces. any is permitted only at a vendor boundary inside a driver package, with a comment naming what it bridges.

D2. Bun by default. Install, dev, build, test, CLI, and the Vercel function runtime. Runner-up was Node default with Bun optional, which loses the zero-config TypeScript execution that makes the CLI and generators pleasant. Constraint: @avelon/core and @avelon/orm may never import bun:*, so Bun is the default and not a requirement. See 11-runtime.md.

D3. Prettier for formatting. Shipped config, zero decisions for the user. Runner-up was Biome, which is faster and does lint plus format in one pass. Revisit if Prettier becomes the slow step in CI. Formatting is not worth a debate, which is the entire point of shipping a config.


Portability

D4. Adapter-first core, only the Next adapter ships in v1. Runner-up was Next-only, which was v1 of this spec. It lost because Next reinventing its conventions every major version is the single largest risk in the project, and adapter-first is the only structural hedge. Cost is roughly 15 to 20 percent more upfront design and the discipline to keep Next types out of core.

D5. A throwaway second adapter is built during M0. Roughly 200 lines of Hono, serving the same controllers, never shipped. An abstraction with one implementation is a guess, and this is the only cheap way to find out whether the contract is secretly Next-shaped.

D6. Driver-first for every external service, not just the database. Runner-up was drivers for the database only. It lost because the same lock-in argument applies to auth, storage, queue, mail, and everything else, and because a driver system built for one capability never generalizes cleanly afterward. See 04-drivers.md.

D7. Capability narrowing at compile time, not runtime feature detection. A driver declares capabilities as literal types and the facade narrows. NotSupportedError is banned. Runner-up was optional methods plus runtime throws, which turns every driver swap into a runtime bug hunt. Verified working against tsc in strict mode.


Structure

D8. Domain directories colocate inside Next's app/. app/Models, app/Http/Controllers, app/Policies sit at the paths the directory map promises. The generated router lives in app/(web) and app/(api). Runner-up was the Next router at src/app with domain code at root, which fails because Next prefers root app/ when both exist, so the domain directory would need renaming and the map that motivates the project is lost.

D9. Writes are generated server actions. A route marked .api() additionally generates a route handler. Runner-up was route handlers plus _method spoofing everywhere, which preserves the real request lifecycle but moves write URLs off the resource paths and gives up the useActionState error round trip. Next cannot serve a page and a POST handler at the same path. People expect POST /posts to sit where GET /posts is, so writes move off the URL entirely.

D10. app/Actions/ exists for single-purpose use cases. Controllers stay thin. A named directory for one-shot work is the pressure valve that keeps them that way, without inventing a service layer nobody agrees on.

D11. app/Wards/ is a sibling of app/Policies/. Two different questions get two different directories. See 06-authorization.md.


Data

D12. The model layer produces a serializable query IR; drivers compile it. Runner-up was building directly on the driver's query builder, which leaks vendor quirks into application code and makes a second database driver a rewrite rather than a package. This is the one decision that cannot be reversed later without touching every model, which is why it moves into M0.

D13. Migrations are driver-owned. The contract is plan, apply, rollback, status. The Supabase driver emits SQL into one Supabase-compatible history. Runner-up was a hand-rolled schema builder DSL. Highest parity, and every Postgres type, index, constraint, and policy would need a wrapper maintained forever. Deferred, not rejected.

D14. Row access is declared once as a Ward and enforced twice. Compiled to RLS where the driver supports it, applied as a query predicate everywhere. Runner-up was writing RLS by hand and documenting it, which is what everyone does today and is why RLS bugs are data leaks rather than test failures.

D15. @avelon/postgres ships in v1 alongside @avelon/supabase. Revised from v2, where it was deferred. Reason: a driver-first framework shipping one driver per capability invites exactly the criticism it was built to avoid, and Supabase-only means the first impression is "no transactions." Two real database drivers is the only thing that proves the abstraction.


Behavior

D16. Events are sync and ordered by default, stoppable, with opt-in after and queued delivery. Runner-up was always-async, which breaks the mental model where a listener can veto an action. See 07-events.md.

D17. No dependency injection container. A typed service registry for driver swapping, plus providers for registration. Runner-up was a full IoC container. It lost on indirection that JavaScript developers reject and on TypeScript generics making resolve<T>() unpleasant. Providers are kept because they are the mechanism that lets a third-party package register listeners and policies without touching your code.

D18. Third-party packages register through providers, listed explicitly in config. Runner-up was auto-discovery from package.json, which is magic that breaks tree-shaking and makes "why is this route here" unanswerable.


Product

D19. Scoped npm packages plus create-avelon. The framework is a dependency, not vendored source. Runner-up was vendoring framework/ into the template: hackable, no version skew, and you can never ship a fix.

D20. Server components with a view() helper, Tailwind 4, no bundled UI kit. reeve ui:install adds a component library if the user wants one. Runner-up was shipping shadcn preinstalled, which is an opinion that ages badly and one most teams override anyway.

D21. bun test as the test runner, with framework helpers and fakes on top. Fast enough to run on save, which is the only property that matters for whether tests get written.

D22. Greenfield only in v1. No adapter for dropping into an existing Next app. Runner-up doubles the compatibility surface before anyone has used the framework once.

D23. Bare reeve opens a TUI. Subcommands with arguments stay fully scriptable and skip the TUI entirely. See 08-cli.md.

D24. Documentation conventions are enforced by tooling, not requested in a contributing guide. See 10-conventions.md.


Revisions from M0

The M0 spike (see .avelon/checkpoints/checkpoint-1.md) disproved assumption 6 and assumption 5 as originally drafted, and left assumption 3 unverifiable without live Vercel access. These three decisions resolve those findings. Made under delegated ownership after Checkpoint 1.

D25. The query IR defines its own semantics; nothing is left to compiler judgment. CompareOp and RelationLoad are exported concrete types. where: Predicate[] composes with AND. ward is ANDed with where. Upsert requires an explicit conflict target and update column list. Empty boolean and list predicates normalize by identity: and: [] is true, or: [] is false, in: [] matches nothing. A const predicate kind expresses always-true and always-false, and a const false ward short-circuits without a database round trip. See 05-database.md. Runner-up was rejecting underdefined shapes at the builder with nonempty tuple types, which pushes the burden onto every machine producer of IR and makes normalization a per-driver behavior, which is exactly the divergence the IR exists to prevent.

D26. Write routes are transport-neutral and views are opaque references. Route.post() describes a write action. The Next adapter serves it as a generated server action; an adapter without server actions mounts an ordinary POST route for the same controller. The serverActions capability selects the transport, it does not gate the route's existence. ViewResult.view is an opaque reference that core never inspects; the configured adapter both accepts and renders it, so under Next it is an RSC component import and under other adapters it is whatever their renderer resolves. Adapters delegate the request pipeline (middleware, binding, validation mapping, exception mapping) to the core kernel rather than reimplementing it. Runner-up was the v2 wording, where serverActions: false made Route.post() a generation error and the view example implied a concrete RSC contract. The M0 Hono spike showed both are Next-shaped: they make every non-Next adapter incapable of serving writes and views that Hono demonstrably serves.

D27. Node is the default deployed function runtime; Bun stays the default everywhere else. Install, dev, build, test, and the CLI run on Bun, all verified locally in M0. The deployed function runtime defaults to Node because Bun-on-Vercel could not be verified against a live deployment, and a prescriptive framework does not ship an unverified default. bunVersion remains a documented one-line opt-in, promoted to default when a live Vercel verification passes. Supersedes the function runtime clause of D2; see 11-runtime.md. Runner-up was keeping Bun as the function runtime default per documentation alone, which trades a support-ticket-shaped risk for no user-visible benefit until verified.

D28. Storage ships upload, download, delete, and signed read URLs in v1. Signed upload URLs and list() are deferred. Recorded at the S2 freeze because StorageCapabilities keys are required literals, so adding one later is a breaking change for every driver author, and this is the most likely place the frozen contract comes under pressure. Client-direct upload is a real need on platforms with request body limits, and until a capability key exists the answer is raw() inside app/Drivers/, which is deliberately non-portable and leaves a searchable trail. Runner-up was adding signedUploads and listing capability keys now. Rejected because neither has a v1 driver to prove the shape against, and a capability invented without an implementation is the guess this framework exists to avoid. An agent that hits the limit escalates rather than improvising.


S2 freeze

The contracts in @avelon/core are frozen as of the commit tagged s2-contract-freeze.

No package may edit a contract to make itself compile, by any route including widening a type or casting through unknown. A package that cannot be built against a contract as written has found a contract bug: it stops and reports.

Four items are deliberately unpinned at the type level and belong to the conformance suites, which are written before any driver:

1. ~~How a request-scoped RequestCookies reaches a config-time identity driver factory.~~ Pinned by the identity conformance suite as (cookies: RequestCookies) => TDriver, a synchronous config-time factory taking request-scoped cookies explicitly. Asynchronous driver setup defers into the driver's own methods. Chosen over an ambient request context because cookie scope stays explicit and independently testable, and over an async factory because a per-request await before every identity call is a cost paid on the hot path forever. 2. count result edges: what rows and affected contain, and whether select: '*' is accepted as equivalent to empty. 3. ~~The drain throw path when a driver declares retries: false.~~ Pinned by the queue conformance suite: the job is dropped after its first delivery attempt. Redelivering would secretly implement retries the driver declared it does not have, and could wedge every later drain behind a job that always fails. A retry-disabled but dead-letter-capable driver may still record the terminal failure; it may not redeliver it. 4. rawBody() on a transport with no wire bytes, such as a server action.

The database suite has since pinned three of its own, recorded here so drivers do not relitigate them:

  • A count result carries rows: [], affected: 0, and count: n. Counting selects no rows and

mutates none.

  • select: '*' on a count is rejected. Requiring an explicit empty projection catches a leaked

query-builder default, at the cost of one explicit field.

  • rpc() against a routine that does not exist raises Invalid, not NotFound, carrying the

routine name in metadata. NotFound maps to a 404 in the central handler, and a missing stored procedure is deployment drift rather than a missing page; surfacing it as 404 would hide a broken deploy.

NotFound is deliberately absent from database conformance. The contract has no must-exist operation, and that concern belongs to the model layer, where findOrFail owns it.

An eager-loaded relation that matches nothing is present, not absent: belongsTo and hasOne keys are null, hasMany keys are []. Pinned by the database suite because Scrivener consumes the shape in Wave B, and a driver omitting the key entirely would force every caller into an existence check the contract never asked for.

Named-instance selection belongs to config resolution and the facade, not to a driver. A driver is one instance and reports it as instance; the { default, disks } style registry in avelon.config.ts is what Storage.disk('archive') resolves against. Wave B owns resolving a name and rejecting an unknown one, and its tests must cover the unknown-name case, because a silent fallback to the default sends real mail from the wrong identity.