Circle V2 API Docs
    Preparing search index...

    Experimental Code

    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.
    • Test coverage is not expected up front. Write tests for the parts you already trust.
    • API churn is expected. Breaking your own consumers within the tier does not need a deprecation path.

    Quarantine covers churn, not correctness or safety. Everything in these docs still applies:

    • Access control on every endpoint (docs/07-trpc.md) — an experimental feature that leaks another org's data is not an experiment, it is an incident.
    • org_id scoping on every query (docs/06-data-layer.md).
    • CircleError subclasses, no PII in messages (docs/08-error-handling.md).
    • Auth, session, and token handling (docs/14-authentication-and-sessions.md) — which is why auth code never goes in lib/experimental.
    • Database schema and migrations. Schema is permanent; it lives in @repo/db and is reviewed normally.

    Code leaves the tier when it has stopped moving. The signals we use:

    • The feature is out of discovery and the customer feedback loop has closed.
    • The API has not changed in the last two cycles.
    • A second, unrelated consumer wants it.

    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