All the decisions of entifix

ADR 0001

The tier contract, and the seam a host configures across

Status
Accepted
Decided
Area
platform

Read whenadding a package, adding a dependency between two, deciding whether something is public API, or about to bake a host's value into framework code — a downward edge can still be illegal, and the seam takes values rather than paths

The records are written in English.

  • Revised: 2026-09-16 by #6 — r10c's tag dimensions retired, and a same-tier edge must now be declared
  • Revised: 2026-09-16 by ADR 0004 — the examples are decided there

Context

entifix is 23 packages extracted from r10c-technologies/r10c, where it had lived as packages/entifix/ with exactly one consumer. Why it left is recorded there, as r10c's ADR 0059; this record is the other half — the rules it lives under now that the consumer is somewhere else.

The extraction worked because two properties happened to hold. The dependency graph was already a DAG with roots that reach nothing inside it — style depends on nothing at all, core on effect and tooling on @opentelemetry/api, both as peers. And exactly one import crossed out of the tree: isEmpty from @r10c/utils-ts-object, in react/integration. Today no @r10c/* import remains anywhere under packages/.

Neither property survives on its own. In r10c they were held by rules that do not exist here: the layer:entifix tag ordering, the scope:shared boundary, and above all one nx run-many -t typecheck,build,lint,test that proved the framework and its consumer in a single command. That command is gone. A change to core is now checked against this repository's examples, and a consumer finds out on upgrade.

What was baked in was never structural, it was product data. The i18n package shipped 'r10c Admin'. The authorization package shipped a table saying a user may ring up a counter sale. require-principal.ts read a cookie named r10c_at. And the principal behind it arrived from Zitadel-backed Layers the framework named directly. Four places, and every one of them a second host would have had to fork rather than configure.

⚠️ One inherited rule is now false here. "Nothing runs in production, so do not design for backward compatibility" is a rule about r10c's local dev fleet. It held while one repository consumed this framework atomically. Published to two consumers it has no cross-repo form: a breaking change in @entifix/core is a change to two codebases that cannot land in one commit. Nothing in this repository inherits it.

Decision

Six tiers, each adoptable without the one above

T0  standalone     style · tooling
T1  entity         core · business
T2  adapters       mongo · sql · redis · amqp · rest · transactions
                   jwt · zitadel · posthog · i18n
T3  ui             react-controls · react-integration
T4  app framework  authz · service-shell · next-shell · next-i18n
T5  testing        testing-unit · testing-e2e · testing-auth

A package may hard-depend only on its own tier or below — and a same-tier edge only where the register declares it. Someone who wants entity metadata takes T1. Someone who wants a table takes T3, and must not thereby install i18next, a Spanish catalog, a Mongo driver and a saga engine.

Two placements are worth stating because both look wrong at a glance:

  • i18n is T2, not T0. It is the i18next binding of the translator port react-controls declares — an adapter to an external library, exactly as mongo is the driver binding of a repository. Only style and tooling are standalone.
  • testing-* is T5, above everything, because a double may impersonate anything. testing-auth in particular replaces TokenServiceTag and PolicyDecisionTag with a service that trusts every token, which is why it is type:testing and why the alternative — an unauthenticated branch in entity-metadata-route.ts for demos — was refused.

Sideways is declared, one edge at a time

Tier order alone would let any package reach any other in its tier: a Mongo adapter into the SQL one, react-controls and react-integration into each other, one shell into another. The packages arrived from r10c carrying a second, finer ordering — entifix:core ‹ contract ‹ {tooling, style} ‹ transactions ‹ client ‹ react, plus layer:*, scope:*, shell:* and runtime:datastore — and in this repository nothing enforced any of it except that ordering, which forbade 133 edges the tiers permit. Almost all of them were sideways.

2026-09-16 (#6). The tags are gone. Two orderings over one graph meant two answers to "may this edge exist", and the finer one was named for a layering whose other layers stayed in r10c. What it protected is kept as SIDEWAYS_EDGES in the register: the seven same-tier edges the framework takes — business→core, the three datastore adapters and rest →transactions, both service and Next shells →authz, testing-e2e→testing-unit — each with its reason. Any other same-tier edge fails @entifix/tiers, and so does a declaration no manifest takes any more.

Deliberately not kept: the handful of downward edges the old ordering also forbade, such as core→tooling. Nothing takes one, and a downward edge that hands an adopter an unwanted capability is what the optional-capability rule below exists for.

The invariant is not about direction

⚠️ A downward edge can still be illegal. Three of the four composition defects found when these tiers were first drawn point downward: react-controls→i18n, the datastore adapters→transactions, and testing-e2e→three database drivers. Every one is legal by tier order and every one hands an adopter a capability they did not ask for. (The fourth, next-shell→authn, left the framework entirely.)

So the rule is: a hard dependency on a capability its tier is meant to be adoptable without is a build failure. Such an edge is expressed as a peerDependencies entry carrying peerDependenciesMeta.optional, reached through a declared subpath export.

⚠️ The subpath is not decoration — it is the part that works. Package-level dependencies are not per-subpath. @entifix/mongo and @entifix/mongo/transactions share one manifest, so the only way the outbox can be optional is for the peer to be optional and the subpath to be the thing that needs it.

The canonical case is the one the tiers were drawn around. react-controls declares the translator itself — useTranslateKey, an i18n-context and a fallback catalog — with @entifix/i18n, i18next and react-i18next as optional peers, and ships the binding that uses them behind @entifix/react-controls/i18next. A table therefore arrives without i18next attached, and a host that wants the binding asks for it by importing the subpath.

The register is tools/tiers/src/registry.ts and tiers.spec.ts checks it in both directions: a package under packages/ missing from the register fails, and a register entry naming a directory that does not exist fails. A package carrying no tier: tag, or a tag disagreeing with the register, fails.

The optional capabilities today:

capabilityoptional forotherwise installs
@entifix/i18nT1, T2, T3i18next, react-i18next and a Spanish catalog
@entifix/transactionsT2the transactional outbox and the saga engine
@entifix/mongoT5the mongodb driver
@entifix/redisT5the ioredis driver
@entifix/amqpT5the amqplib driver

An exemption is named one at a time, with its reason on the line. There is one: @entifix/rest hard-depends on @entifix/transactions because the REST save adapter writes makeCommandEnvelope onto the wire and reads readTransactionAcceptedEnvelope back. That envelope is the shape of a save in this framework rather than an optional extra — the sink is already optional at runtime, asked for with Effect.serviceOption. A subpath there would hold the whole of entity CRUD over REST. A new package in the tier still fails by default; that is the point of naming exemptions rather than widening the rule.

The seam takes values, never file paths

Four places had a host baked into framework code, and all four resolve the same way: entifix declares a port, the host supplies a value at composition.

whatentifix declaresa host supplies
catalogsa namespace per package for the copy it renders, and defineCatalogsits own namespaces, merged, plus the typed-key gate
grantsPolicyDecision, PolicyDecisionTag, can(grants, …)its grant table
cookie and storage namesneutral names, fixed, each declared by the package that reads itnothing
the principalTokenServiceTag, PolicyDecisionTagthe Layers that verify a real token

A value, not a path. A framework that takes ./config/roles.json has to resolve, read and parse it — it now owns a filesystem contract, a path convention and a failure mode per host, none of which is its job. TypeScript is the default supply, because a Permission is a template-literal type and Record<Role, readonly Permission[]> makes a typo a compile error. JSON is accepted, validated at the port through Standard Schema, for a host that must configure at runtime.

A Layer, a provider or a hook option wherever a composition root exists; a module-initialization registry only where one does not. There is exactly one of the latter, and it is not a preference: getServerT is called directly inside React server components scattered across an application, with nothing in between to thread a parameter through. So catalogs are installed by defineCatalogs(…), once, from a module every entry point imports — and the failure mode is made loud, an EntifixBuildError naming the call to make, rather than a silent empty default.

⚠️ The typed-key gate is the one seam that cannot be split. TypeScript permits exactly one declare module 'i18next' { interface CustomTypeOptions } per compilation; a second is TS2717: subsequent property declarations must have the same type. So catalogs are owned per package but the type gate is composed by the host, in about ten lines. Every useT call site reads exactly as before. What an adopter gains is a file they must write, and it belongs in the README rather than in a paragraph they reach after the first compile error.

⚠️ The registry is installed once per bundle, not once per application. A Next application's server and client are separate bundles with separate module state. Installing only from a 'use client' provider leaves every server render throwing No i18n catalogs are installed, and no unit spec can see it because specs install catalogs themselves. A host installs from its root layout for the server graph and from its provider for the client.

Each package owns its namespace

controls ships with react-controls; entity and errors ship with core; shell ships with next-shell. Those never vary per host, so useT('controls') stays internally typed forever and a framework string is never a host's problem. What crosses the seam is a translator instance and a locale — never a catalog.

Names in the framework are neutral, and fixed rather than configurable

The session cookies entifix_at, entifix_sid, entifix_did and the locale cookie entifix_locale are declared in core, beside the helpers that read them. The bus exchange entifix.events and its entifix.events.dlx are declared in amqp. The two IndexedDB database names — entifix-workspace in next-shell, entifix-ui-preferences in react-controls — are declared where the store they name is opened.

Fixed, not an option, and that is the deliberate half. A cookie name that varies per host is a name every middleware, route handler and test helper must be handed; the configuration cost lands on every call site to buy a rename nobody needs. A host adopts the names.

The exception is a key that was already a parameter: react-controls' theme storageKey and its UI-preferences namespace keep taking a value, and what changed is only that their defaults are now 'entifix-theme' and 'entifix-ui' rather than a product's name. A neutral default is not the same decision as a seam, and this record does not turn one into the other.

⚠️ testing-e2e re-declares the three cookie names rather than importing them, and the reason is a resolution one rather than a tier one. Nx builds the project graph by loading every e2e project's playwright.config.ts in plain Node — no source condition, nothing built — and that config reaches playwright/session.ts. An import … from '@entifix/core' there resolves core's dist, which a CI job that only lints never builds, and the whole graph fails with Cannot find module …/dist/index.js. It passes on a developer machine only because dist is left over from a previous build. The duplication is checked rather than trusted: session-cookies.spec.ts imports core and fails if the two drift.

Public API is what a package exports

Exported from index.ts, or reachable through a declared subpath export, is public. Everything else is internal and may change in a patch. Today that is 23 packages and 24 subpaths.

Neither versioning rule below means anything without this line: without it, every file in src/ is API and no change is ever a patch.

Subpath exports therefore do double duty — they are the API surface and the unit an optional peer hangs off. Adding one is an API decision, not a file-layout decision.

Fixed versioning, first release 0.1.0

All 23 packages carry one version and move together. No compatibility matrix to maintain, and every entifix-to-entifix peer — workspace:* here, an exact version once published — is trivially correct because there is only ever one version in play. Peers on the outside world (effect, react, i18next) stay ranges; those are the adopter's to satisfy.

At 0.x a breaking change rides the minor: 0.1.x is the patch channel and 0.2.0 is the breaking channel. Both consumers upgrade together.

Independent versioning stays available. The trigger to revisit is 1.0.0, or earlier if a consumer ships to someone who cannot upgrade on our schedule — at 0.x the matrix is real work and the version noise is cosmetic, and that trade inverts the moment an upgrade stops being ours to schedule.

This collection starts at 0001; r10c's records stay in r10c

All 59 of r10c's records stay there. They are one connected body — supersessions run across them and the reasoning behind @entifix/next-shell is inseparable from the back office it was drawn for. Two collections numbering from a shared origin would collide on the next decision either side took.

entifix source cites 42 of those records, 337 times. Those citations are rewritten to this collection's own records as it writes them, one commit at a time, rather than migrated in a batch: a citation is rewritten by the record that replaces what it points at, and there is nothing to point at yet. The appendix below is what stands in until then, and it is load-bearing — r10c is public now and private later, so a bare ADR 0030 in a source comment becomes a dead reference to a repository a reader cannot open.

Consequences

  • Back-compat is a real constraint here, and it is new. Nothing in this repository inherits r10c's "no compatibility shims" rule. What replaces it is the versioning channel above, not a dual-read window.
  • An adopter's first task is the i18n augmentation, before anything renders. Ten lines, and a wrong one is a compile error rather than a runtime surprise — but it is the first thing they meet, so the README opens with it.
  • Three packages ship unexercised. @entifix/zitadel, @entifix/jwt and @entifix/posthog have unit tests and no example. The README says so, rather than letting an adopter infer coverage from their presence.
  • The register is a declaration, not a scan, and has to be edited by hand. A scan can only say what the code currently does; the point of a register is to say what it is allowed to do. Adding a package means adding a line, and forgetting is a build failure rather than a silent pass.
  • The citation rewrite is a long tail. 337 references do not clear in one pass, and every one still pointing at r10c is a reference that goes dark when that repository turns private. The appendix bounds the damage; it does not remove it.

What this record does not decide

The publishing mechanism (npm provenance, the release workflow), the local development loop between this repository and its consumers, and the three example applications. Each is its own decision in this collection — the examples are ADR 0004.

Appendix — the r10c records entifix source cites

One line each, so a reader who meets ADR 0030 in a source comment is not stranded. Counts are citations in packages/entifix at extraction.

r10c ADR×What it decided
003624The reactive stream is server-sent, same-origin, scoped per connection — SSE, not a socket.
002623The use-case descriptor, served from $metadata filtered by the verified principal.
003922Multi-step sagas are orchestrated per flow from a declarative definition.
003020Failure, retry and quarantine on the bus — three failure classes, work vs broadcast.
003218What may live in an autosaved draft — JSON round-trippable only, keyed per principal.
005218The checkout saga: the definition is data, and a fan-out compensates only what succeeded.
003116A service describes its own wiring; /api/$service diffs it against the register.
003316The screen taxonomy: Definiciones, Operaciones, Asistentes, Consultas.
003515Entity actions: where a verb appears, and what a bulk action acts on.
004514The wizard — a step graph that is data, a draft above the forms, a submit that hands off.
002313A service reaching tenant storage for another party names the organization explicitly.
002813The transaction id is the client's, and its event is written to the outbox with the write.
004312The optimistic mutation contract — a 202, and reconciliation is a re-query.
005511A coordinator resumes from its own record; a dispatch needs no outbox.
004010The record search aggregator fans out per request and ranks nothing.
00349Composition metadata: an entity can declare that it owns a collection.
00429The workspace address is the taxonomy serialized — master:<key>[:<id>].
00227The v1 marketplace's final domain, store and slice boundaries.
00297The event envelope and a routed bus; the dedup key is event.id.
00106Stock as a movement ledger; a purchase reserves rather than decrements.
00596entifix leaves the repo, and r10c becomes one of its consumers. (This record's other half.)
00074The access model: planes, platform roles, tenant-defined roles, entitlements.
00094Catalog authoring in the tenant plane, publication into a platform read model.
00204Stores and Slices: the unit of data ownership and the unit of deployment.
00214Consolidating the fleet into five deployments.
00274Two scales, a density mode, and the type system — one token set, two scales.
00013Observability and platform tooling — the Effect→tooling bridge, metrics and sinks.
00383Master-detail: a record and the rows it owns, edited in one write.
00543Capture is the pivot, and the bus carries what follows.
00062Multitenancy: three planes, ambient tenancy, storage per organization.
00122Operator cross-tenant access is an audited crossing, never a bypass. (Proposed, unbuilt.)
00162Zitadel authenticates; r10c authorizes and mints its own tokens.
00192A user deactivated at the provider loses their r10c sessions.
00372Entitlements ride the access token, and navigation reads them.
00582The order after payment: fulfil, cancel, refund, and a capability at rest as a digest.
00021Authorization: role aspects behind an ABAC-shaped port; grants never come from the token.
00141Vendor-authored entity specifications, pinned per instance. (Proposed, unbuilt.)
00151Asymmetric access tokens, and the party role as a claim rather than a grant.
00171Back-channel logout: the provider can end a session.
00411The sidebar renders the taxonomy, and a shell may ask about the viewport.
00441The command palette owns no index, and its depth is a page stack.
00481Announcing a publication, and a shared contract to announce it with.