We ship some features to customers before we know what they should look like. The feedback loop is the point: we put something in front of a customer, learn from it, and change it again. Code written that way does not deserve the same permanence as the rest of the codebase, and pretending otherwise slows the loop down.
The experimental tier is where that code lives. It is quarantined so that the churn stays contained, stays visible, and can be paid down deliberately later instead of leaking into packages we consider settled.
| Location | Holds |
|---|---|
packages/experimental-ui |
Client-side code still being shaped |
packages/experimental-server |
Server-side code still being shaped |
apps/web/shared/experimental |
Domain-aware components shared by 2+ experimental features |
apps/web/lib/experimental |
App-level helpers, stores, and hooks for experimental features |
Single-feature code still belongs in apps/web/app/(app)/<feature>/. The experimental tier is for code that
outgrew one feature but has not earned a place in the stable packages.
Experimental packages are namespaced by feature: packages/experimental-ui/src/<feature>/ and
packages/experimental-server/src/<feature>/, exported as @repo/experimental-ui/<feature> and
@repo/experimental-server/<feature>. Nothing sits flat at the package root.
The tier is shared by every experiment in flight at once, so the package root is not any one feature's to
spend. A viewer that drops useLazyComponent.ts and testing/renderHook.tsx beside src/index.ts has taken
two names the next experiment wanted, and the collision arrives as a merge conflict instead of a design
conversation. Feature folders also make the review cadence below mechanical: a feature that graduates or gets
deleted is one directory and one export subpath, not a diff hunt.
| Feature barrel | Import as |
|---|---|
packages/experimental-ui/src/pdf/index.ts |
@repo/experimental-ui/pdf |
packages/experimental-server/src/pdf/index.ts |
@repo/experimental-server/pdf |
Each feature owns exactly one barrel and one export subpath. The split between the two packages is client
versus server, not feature versus feature: the PDF viewer and the PDF fill pipeline are one experiment living
in two packages under the same feature name. There is no root "." export on either package — a package-wide
barrel would re-merge what the folders are there to keep apart.
One feature has a second subpath: @repo/experimental-server/rulebook/wording is the rulebook's labels, which
the editor and the stored verdict must word alike. It imports only @repo/db/schema types and pluralize, so a
client component can import it without pulling the rulebook services into the bundle.
A feature may leave a small, frozen contract behind in a stable package. @repo/pdf is one: it holds the
field-box types and the coordinate math that both halves of the PDF experiment read, and @repo/db reads the
same types when it stores form runs. That is the only reason stable code is ever allowed to name part of an
experiment — the contract is stable code, deliberately kept additive-only and small enough that calling it
frozen is honest. Everything still moving stays in the tier.
Experimental code may import stable code. Stable code may never import experimental code.
This is what makes the tier a quarantine rather than a label. It means deleting or rewriting anything in the
experimental tier can never break @repo/ui, @repo/services, or any other settled package.
apps/web is the exception: app code is allowed to import from the experimental tier, because shipping the
feature is the whole point. A Biome rule enforces the restriction for packages/**.
Every stable-to-experimental import is named here with its reason, and is scoped in biome.json to one
directory and one specifier. There is no blanket exemption.
| Consumer | Imports | Why |
|---|---|---|
The form-filling service in @repo/services (packages/services/src/form-filling/) |
@repo/experimental-server/pdf |
The service owns form runs and is stable, but the fill pipeline it drives (toWritablePdf, detectFormType, drawAnswers, flattenForm, isHybridXfaForm) is deliberately kept in the server tier beside the viewer so that all PDF code lives together in one experiment. The service layer stays settled; the pipeline stays free to move. |
The form-templates service in @repo/services (packages/services/src/form-templates/) |
@repo/experimental-server/pdf |
detectFields drives AcroForm field extraction (extractFields) for template setup. Same split as form-filling: the service owns the template's lifecycle and stays settled; the extraction pipeline lives with the rest of the PDF experiment. |
The form-templates service in @repo/services (packages/services/src/form-templates/) |
@repo/experimental-server/extend |
The Extend form-detection client is a raw fetch wrapper around one vendor's API family: its response shape, its pinned API version and its detection engine all move on the vendor's schedule, not ours. It sits beside the PDF pipeline in the tier because the two are one flow — Extend draws the boxes, @repo/experimental-server/pdf reads and fills them. The submit/poll services own the run's lifecycle and stay settled; the transport stays free to churn. ExtendFormDetectionError is not part of this edge: it stays in the stable @repo/ai/extend/errors, shared with the production RPA webhook path, and is what stable code branches on. |
The form-templates router in @repo/trpc (packages/trpc/src/routers/form-templates/) |
@repo/experimental-server/pdf |
Template upload refuses PDFs the fill engine cannot use (checkTemplateSuitability) and detection maps the pipeline's PdfParseError to a tRPC error. The router is stable transport; the suitability and extraction rules it consults are still moving with the rest of the PDF experiment. |
The utilization router in @repo/trpc (packages/trpc/src/routers/utilization/) |
@repo/experimental-server/utilization |
The router is stable transport: it applies canAccessPatient and hands the repo's projected Kipu group sessions to the parser. How a group session becomes an attended, timed, billable event (parseKipuGroupSessions) is the part of the Utilization experiment still moving against customer feedback, so it stays in the tier. The repo method and its SQL projection are stable code in @repo/db. |
The rulebook router in @repo/trpc (packages/trpc/src/routers/rulebook/) |
@repo/experimental-server/rulebook |
Four procedures reach across, and the router is stable transport for all of them: it applies adminProcedure and the enableRulebook gate, adds canAccessPatient on the two patient procedures, stamps triggeredBy at the call site, and shapes the DTO. evaluateRuleNow (evaluate-rule-now.mutation.ts) calls evaluatePatientRules and then readPatientFindings; listFindings (list-findings.query.ts) calls readPatientFindings; create (create.mutation.ts) and revise (revise.mutation.ts) both call createRuleVersion. What those own is the part still moving against customer feedback: what an answer is (EvaluateRuleService, its indeterminate gates and the existence grader), which answers a patient currently has, and what a version needs before it can be stored — the concept check, the trigger-column mapping and the conceptsWithNoFacility report. The tables, the repos and the rule vocabulary are stable code in @repo/db. |
The biome.json override for packages/services/src/form-filling/** re-declares the restriction with
@repo/experimental-server/pdf negated out of the group. Everything else stays forbidden there: the UI tier,
bare @repo/experimental-server, deep paths under pdf/, and any @repo/experimental-server/<feature> subpath
added later. The negation names one barrel, not the package.
Adding a second edge means one of two things. Either the module has stopped moving and should graduate, or it has not and the new import gets its own row here, its own reason, and its own equally narrow override. Widening an existing override, or exempting a whole package, is never the answer.
noExplicitAny is a warning rather than an error. Rough types are acceptable while the shape is in motion.Quarantine covers churn, not correctness or safety. Everything in these docs still applies:
org_id scoping on every query (docs/06-data-layer.md).CircleError subclasses, no PII in messages (docs/08-error-handling.md).lib/experimental.@repo/db and is reviewed normally.Code leaves the tier when it has stopped moving. The signals we use:
Graduating means moving the file to the stable package it belongs in, tightening the types, and adding the tests that were skipped. It should be its own PR, separate from feature work.
The experimental tier is reviewed once a cycle: anything that has stopped moving graduates, anything that lost its feature gets deleted. Code that sits here untouched for more than two cycles with no owner is a deletion candidate, not a fixture.
Next: Repository Structure