Canonical design invariants
The settled model, mined verbatim from the design conversation. Authoritative where any other design doc conflicts — including function-core-and-projection.md, which is fuller but partly superseded. Each invariant pairs a crisp statement with the user's own words; the quotes are load-bearing and preserved verbatim.
Identity
Fractal is a codebase compression substrate. It gives codebases a skeleton — the central structure supporting the entire app — as a single source of truth, with everything else derived from it.
The type IR, routing tree, and (future) operation layer are all aspects of this skeleton: one declaration IS the truth, and the surfaces (HTTP routes, CLI commands, OpenAPI specs, validation schemas, admin UIs, audit logs) flow from it.
The previous label — "Parsec-style combinator composition" — was aspirational naming that didn't match the built code. The actual pattern: inspectable declarations (data) interpreted by projectors to produce surfaces. The combinator identity gap (TODO.md) was a symptom of unclear self-description, not a structural deficiency in the code.
Structure is optionally part of the skeleton
Stale constructor names (docs verification pass):
node({children: ...})andservice()below name constructors that existed at the time this was written but have since been unified away — current code has onlyop(fn, ...contributions)(leaf) andapi(children, opts?)(branch), both inpackages/api-tree/src/node.ts(see commitebc0064, "unify node()/api() — single api() constructor"). Class-shape structure inference (service()'s role, #3 below) is not implemented in current code — nofromClass-style inference exists in any package. The general principle (explicit vs flat vs inferred structure is a deployment choice) still holds; only the concrete constructor names and the "already an instance of #3" claim are stale.
The skeleton's navigable structure (how operations are organized) can be:
- Explicit — declared as a tree (
node({children: ...})) - Flat — operations declared with no structure; projectors derive it by convention
- Inferred from X — structure read from an existing organizational signal (class shape, module layout, other codebase metrics)
service() is already an instance of #3: it infers skeleton structure from class shape. The general principle: if the codebase already has structure, don't redeclare it — read it. How much of the skeleton is declared vs inferred is a deployment choice, not an architectural fork.
API tree and route tree are separate
The skeleton (API tree) is organized by domain — children are operations, not path segments. Protocol-specific trees (HTTP route tree, CLI command tree) are projections produced by Node => ProtocolType transforms — they cross a type boundary, not endofunctors. Two operations that share an HTTP path are different nodes in the API tree.
Convention transforms (REST/CRUD reshaping) are plain endofunctors (Node => Node), chainable, and run on the API tree before projection. Rewriters are endofunctors within the protocol type (ProtocolType => ProtocolType), applied after projection. The structural primitive is relative node placement — each node specifies where it goes in the output tree via a relative path string (stringly-typed, acceptable because it's transform input, not skeleton structure). * marks wildcard segments.
SUPERSEDED / CORRECTED (see converged-model.md)
Added 2026-07 after the operation/projection model converged. The current authoritative synthesis is
converged-model.md; prior art isserver-less(/home/me/git/rhizone/server-less), which already implements the model. This section corrects specific items below that over-blessed assistant-invented material. Everything not listed here remains as stated.
(a) The
read→GET / replace→PUT / remove→DELETE / partial→PATCHverb table is REJECTED. It was assistant-invented, not a designed operation kind. Verbs are one lossy, downstream HTTP projection — not an agnostic kind/object. (Corrects the "CRITICAL" note under the POST invariant and Open question #1.)(b) The operation-characterization is ARBITRARY, OPEN metadata — not a designed kind/verb set. An op is a function carrying an open metadata bag; each protocol projection reads the keys it recognizes and ignores the rest. Metadata is only for non-type-expressible projection/taste concerns (verb, idempotency, cache, auth) — never a second source for domain data (types + JSDoc remain that).
(c) Any claim that the wire surface is "generated unaided with zero config" should read as "unaided defaults for the obvious + overrideable metadata for taste." No deterministic program can divine taste; inference is a fine but always OVERRIDEABLE default, never authoritative. (Tempers the "codegen just works for whatever's obvious" invariant — losing unaided-projection-for-the-obvious is a dealbreaker, but total/objective projection is wrong.)
(d) Current authoritative synthesis:
converged-model.md; prior art:server-less.
Settled invariants
Core = plain functions + composition. The base is
T => Uplus(.) :: (a->b) -> (b->c) -> (a->c). Kleisli/applicative forms are DERIVED and strictly less general."the handler itself should be T => U, the transform should be arbitrary T => U as well. and then:
(.) :: (a -> b) -> (b -> c) -> (a -> c)..." "i don't see how kleisli arrows aren't OBJECTIVELY strictly less general" "we want to build a library/framework to transform/manipulate arbitrary data into arbitrary other data"One-directional transforms; no view/review.
"wait, why do we need
review." "view + review is overkill and poisons the architectural purity of composability" "correction: it's just a fucking FUNCTION"Handler = a simple
f(options) => Resultover a strongly-typed named-params object; not a wirebody."the handler transformation function to be, well, a 'simple' function from T to U" "building up a typed 'context'/'options'/'parameters' object as the input to an arbitrary api function" "
bodyis the wrong input for the options object. it should be a strongly typed 'named params' style object" "the router itself imo should be a more or less shallow shim that reconciles the input with the statically typed api function"Handler is provenance-blind; HTTP source-markers and capabilities do not pass through to it.
"and NEITHER of these should pass through to the api function" "do caps not just... sit in the input shape and pass through untouched?"
Request => TandU => Responseare plain functions — optionally handwritten, otherwise projected; not declarative markers."f(Request) => Response. values are FUCKING PROJECTED (Request => T; U => Response)" "in manually authored HTTP trees, Request => T are OPTIONALLY handwritten as REGULAR FUNCTIONS because OBVIOUSLY" "E -> HTTP Response is another plain function that the http package should probably export for convenience"
Inputs are already typed; there is no "raw". Coercion is impossible-by-construction via the type system.
"strings are already typed" "input is not 'untyped', it is fully typed" "a fucking string "id" is fucking fine" "coercion is an implementation detail, and this should be impossible by construction... via... the type system"
Single source of truth = inferred TS types + JSDoc (constraints AND descriptions). No reified runtime meta / no schema-as-second-source.
"who the fuck said reified tree" "why not? types are readable by typescript api" "the fuck? hello? jsdoc says hi?" "not to mention fucking descriptions are also readable from jsdoc..." "didn't we agree to use inferred types?"
(An earlier
{closure, metadata}idea was floated, then ABANDONED — do not resurrect it.)Codegen "just works for whatever's obvious"; the user writes as much or as little as they want; output must be as good as handwritten, structurally and semantically.
"the point of codegen is to just work for whatever's obvious" "as good as a handwritten one structurally and semantically" "codegen'd is more consistent = better"
CORRECTED (c): read "generated unaided / just works" as "unaided defaults for the obvious + overrideable metadata for taste" — never total/objective projection. See
converged-model.md.One explicit nested routing tree; combinators name the node kind via a record API (
path({ classes: ... })); notree([]), no opaqueleaf, no scattered declarations."when routing is just multiple unrelated declarations then the mental model of how routing slices is fucking intractable" "what the fuck is tree([]). why not path({ classes: })" "what happened to the fucking routing combinators" "what the fuck is leaf. a) slop b) monolithic"
No colon path-DSL, no bound-variable machinery; a plain string segment suffices.
"how. the fuck. would bound variables even work. a fucking string "id" is fucking fine"
The IR is the abstract (protocol-agnostic) tree; the HTTP tree is a projection of it; the tree stays explicit but not protocol-specific.
"the tree (if any) should still be explicit, no? just not a protocol specific one." "keep in mind that decisions may not cut across a clean axis."
NoInfer<T>+ a root anchor is the inference mechanism. The mechanism is accepted; the assistant's "bottom-up forces it" rationale was NOT accepted."says who, exactly?"
POST = a method call / invocation.
new T()/ "create" is NOT a method call. Rejectcreate → POST /collectionand arbitrary name→verb tables."i am FUCKING SAYING post is a METHOD CALL and
new T()is NOT A FUCKING METHOD CALL" "why the FUCK does create translate to POST /todos" "POST is method call not fucking create... i'd probably prefer POST /the/:path/here/new"CRITICAL: the
read→GET / replace→PUT / remove→DELETE / partial→PATCHscheme was assistant-invented and is NOT user-settled. It is recorded here only as the assistant's unconfirmed proposal — it is not part of the model. CORRECTED (a/b): this table is now explicitly REJECTED; verbs are downstream projection metadata, not a designed kind. Seeconverged-model.md.**verb / path / placement (query/body/header/cookie) are projected from binding
- convention; never authored as ceremony on the leaf.
InputSource-style enums are an HTTP-shape leak.**
"f(Request) => Response. values are FUCKING PROJECTED" "disturbingly http shaped"
- convention; never authored as ceremony on the leaf.
methods({})is an HTTP construct that must still exist for dispatch; bespoke verb/path = explicit overrides."how the fuck is methods() not a fucking HTTP construct" "where the FUCK did methods({}) go"
STALE (2026-07-10):
methods({})as a distinct combinator was replaced bymeta.http.dispatch = "method"on a node — seerouter-model.md. The intent (HTTP method-dispatch must exist; bespoke verb/path = explicit overrides) remains valid; the mechanism changed.API-first; clean interfaces; the HTTP router is both dogfood for the generic core AND a wanted product with SOTA DX + SOTA perf.
"the interface(s) themselves should be clean."
"data over code" is NOT a forcing principle here (it was poisoning context).
"the data over code thing is kinda poisoning context"
Tagged-union discriminant fields are named
kind, nottypeand not per-site names likeby.Applies to all serializable tagged-union ("frozen call") data —
MatchCondition,meta.http.dispatch, and any future plug-in or config data of the same shape.Rationale: this codebase reifies types as its core thesis ("the typed thing is the truth; type is inferred from TS"), placing it in compiler/language-tooling territory (Rust
ExprKind/TokenKind, Clang/LLVM AST, Kuberneteskind) wherekindis standard precisely to avoid colliding with the loaded word "type." Readingtype: "date"in a system where "type" means the inferred TS type is genuinely ambiguous;kindis not.The one override: when serializing INTO an external wire format that fixes its own discriminant (e.g. JSON:API / Redux use
type), match that format at the boundary. The internal convention iskind.Shape framing: such a value is a frozen function application —
kindis the callee (which matcher / variant), the remaining fields are its arguments; the nullary case may degenerate to a bare string tag (e.g."method"). The discriminant exists because the value is data resolved to a function later by the projection — a closure at that boundary would lose serialization/introspection.Reject
Result<T,E> | Responseescape hatch; want a canonical stream construct."why not a canonical stream construct?"
Rewrite salvaging infra; read existing code critically, not blessed.
"existing code should be read critically not treated as blessed"
Open questions
Unsettled — each must be resolved FROM the user's definition, not guessed.
Status update (docs verification pass, cross-checked against
TODO.md§ "Unsettled design questions"): most of these have since moved. Kept below as originally recorded; status noted inline. Only #5 and #7 remain genuinely open.
The full verb/method model beyond "POST = method call"— RESOLVED (2026-07-17) perTODO.md.- Can one tree auto-derive both HTTP and CLI, given "http path/headers vs cli subcommands/env vars have no 1:1 mapping"? (User "kms"'d at "no single tree auto-derives both"; unreconciled.) — Reframed, not literally resolved: structure is optionally part of the skeleton (see § Identity above); each projector still derives its own protocol-specific tree from the one API tree, rather than one tree mapping 1:1 onto both.
- Node disambiguation: segment vs operation vs param within one node; where the input→options transform lives. — Partly addressed via the
fallbackfield (packages/api-tree/src/node.ts); the input→options transform lives inassemble()(packages/api-tree/src/input.ts). Authoring form for bespoke verb/path overrides— SETTLED:meta.httpis a DU interpreted by the projector (seedocs/design/router-model.md).- Higher-level magic/decorator/metadata layer (user is "not against" it; undesigned). — still open.
Creation / non-record output encoding(user leans explicitPOST /…/new) — CLOSED (2026-07-17) perTODO.md: purely theoretical, no concrete need materialized.- "Is it too general?" — still open per
TODO.md's own "Unsettled design questions" list, though a separate backlog entry in the same file marks a same-named item "DISSOLVED" — the twoTODO.mdlists disagree with each other; not resolved here.
Guardrails
The assistant regressed on these repeatedly. DO NOT repeat them.
- Do not reintroduce a reified runtime meta/schema tree as a second source — truth is inferred types + JSDoc.
- Do not treat input as "raw/untyped" needing validation — inputs (incl. strings) are already typed.
- Do not leak HTTP shape (
body/query/header/verb/InputSource) into the handler/options — those are projected, never reach the api function. - Do not propose bidirectional view/review or Kleisli-as-base — base is plain
T=>U+.. - Do not lose the single explicit nested tree (no descriptors/flat declarations/
tree([])/opaqueleaf); keep record combinators +methods({}). - Do not map
create→POSTor invent name→verb tables — POST is a method call. - Do not force "data over code" / "compiled from descriptors."