This file is the tie-breaker. When two styles seem plausible, use the one
written here. Every rule below is either enforced by pnpm guardrails
(marked enforced) or verified by review (marked review).
The reasoning behind each rule lives in docs/decisions.md ("why §N" below). Read the matching entry before arguing with a rule or extending one — the rationale states what would have to be true for the decision to change.
When a rule exists as data, consumers derive their behavior from that data instead of re-checking it at call sites (why §18). The template's authorities and their derived consumers:
ApiRoutes→ server routing, param/body decoding, client requests.- Transition tables in contracts (
allowedAgentRunTransitions,allowedGraphRunTransitions,allowedGraphNodeTransitions) → SQL guards, coordinator logic, and generated XState machine transitions. runStatusForEvent→ journal projection, client cache projection, and run-machine STATUS events.- Terminality is
allowed*Transitions[status].size === 0via theisTerminal*Statushelpers — never an inlinestatus === "completed" || ...triple. - The DESIGN.md palette → Tailwind theme tokens → utility classes.
The smell this rule bans: if (state !== "x" && state !== "y") before
sending an event, tag-matching chains that shadow an existing projection
function, or any second copy of a table. If you need a new rule, make it
data in contracts and derive from it.
src/
index.ts deliberate public exports only
service.ts Context.Service capability + its tagged errors
model.ts public schemas and branded identifiers
errors.ts cross-cutting errors shared by several services
live.ts barrel of every production layer in the package
internal/ SQL, SDK mapping, and helpers (one *-live.ts per service)
test.ts deterministic in-memory implementation
- Every production
Layerlives ininternal/<name>-live.tsand is exported throughlive.ts. Small deterministic Test layers may live beside the public service contract; they never import SQL or provider SDKs. - No file imports another package's
internal/path (enforced). - Deterministic doubles (
*Test,*Fake,make*Test) are real providers: local development selects them throughAppConfig(AI_PROVIDER=fake,SANDBOX_PROVIDER=fake). They ship from the public barrel on purpose.
Declare capabilities as Context.Service classes (why §2) and errors as
Schema.TaggedErrorClass (why §3):
export class ProjectNotFound extends Schema.TaggedErrorClass<ProjectNotFound>()(
"ProjectNotFound",
{ projectId: ProjectIdSchema },
) {}
export class ProjectService extends Context.Service<
ProjectService,
{
readonly get: (
scope: AccessScope,
id: ProjectId,
) => Effect.Effect<Project, ProjectNotFound | PersistenceError>;
}
>()("repo/ProjectService") {}- Cross-cutting errors (
PersistenceError) live inerrors.ts, never inside one domain's service file. - Raw SQL belongs in data-access modules (why §9):
packages/db,packages/queue/src, or a package'sinternal/*-live.ts(enforced). When an app file genuinely must issue SQL (a readiness probe, an app-owned port binding), annotate the file with// architecture-allow: raw-sql -- <reason>so the exception is visible and justified. - Decode every row leaving SQL with
Schema.decodeUnknownEffect; normalizeDatecolumns withnormalizeTimestampsfrominternal/sql-helpers.tsinstead of writing a new inline converter. - Live layers take time from the Effect Clock (why §10) — use
nowTimestampfrominternal/sql-helpers.ts(core) or a localClock.currentTimeMillismapping — nevernew Date()/Date.now()(enforced; escape hatch:// architecture-allow: wall-clock -- <reason>). Test layers keep fixed ISO strings, and TestClock can now drive Live layers deterministically.
Ports that wrap an external system (AiService, SandboxWorkspace,
AgentRuntime, SecretStore) are plain interfaces with make* factories —
they are constructed and wired explicitly in app entrypoints, not resolved
from the Effect context (why §2, §8). When adding a provider:
- The port package owns the interface, repository schemas, and one tagged
error union with an
operation,reason, andretryablefield. - The adapter lives in a dedicated package (
sandbox-opensandbox,agent-runtime-opencode) or behind a deliberate subpath export (@repo/ai/openai). Provider SDK imports stay inside the adapter (enforced). - Decode every SDK response with a repository schema before it crosses the port boundary. No SDK type appears in a port signature (review).
- Map SDK failures into the port's tagged error and preserve the
distinguishing reason; do not collapse everything to
unavailable. - Ship a deterministic double next to the port (
test.ts) that implements the same interface without processes or network.
ApiRoutes in packages/contracts/src/http.ts is the single authority for
the public API (why §5): method, path template, branded param schemas, request and
response schemas, and success status. The server router iterates the table
and dispatches to an exhaustive handler map; the Effect client builds every
request from the same definitions. To add an endpoint:
- Add request/response schemas to the owning
packages/contractsmodule. - Add the route to
ApiRoutes— the server now fails to compile until a handler exists inapps/server/src/api.ts(RouteHandlersis keyed byRouteName). - Write the handler: it receives schema-decoded
paramsandbody. - Add a client method in
packages/client/src/client.tsusingbuildPath(ApiRoutes.<name>, params)and the route's schemas, and expose it through the Promise facade (promise.ts). - Add an
errorStatusentry inapps/server/src/api.tsfor every new tagged error the handler can surface. Unknown tags intentionally become 500. - Add a query/mutation option factory in
packages/client-reactwhen the web app consumes the endpoint.
packages/contracts/test/http.test.ts guards table integrity (param/token
agreement, no duplicate method+path, matcher round-trips).
packages/client/test/client.test.ts compares coveredClientRoutes with the
table, and PromiseAgentClient is mapped from AgentClient, so either facade
fails loudly when the public surface changes.
Every response carries x-request-id. Unexpected defects are logged only at
the app boundary with that ID and safeErrorDetail; adapters preserve
not-found, forbidden, rate-limited, and unavailable reasons without retaining
raw provider objects or credentials.
process.envis read only inpackages/configand appmain.tsentrypoints (enforced).decodeAppConfigthrows on invalid boot configuration by design (why §7): a config error must kill the process before any listener starts. Everything after boot receives the typedAppConfigvalue.
- TanStack Query owns remote state; query keys and option factories live in
packages/client-react. An unavailable branded ID is represented byundefinedplusskipToken, never a fabricated empty ID. - XState owns only real workflows (active run, approval, reconnect).
- Base UI is imported only inside
packages/ui;radix-ui/cmdkonly inside the vendoredapps/web/src/components/ui/directory orpackages/ui(enforced). - Visual tokens come from
apps/web/DESIGN.md(why §15), are declared as Tailwind@themecolors insrc/styles.css, and are used as named utilities (text-blueprint,border-line). Hex literals in non-vendored web code and raw palette utilities in owned web/UI code are rejected (enforced), andsrc/design-tokens.test.tsfails when DESIGN.md and the CSS theme drift apart. Update the contract and the code in the same change. Rich transcript rendering stays behind the lazyRunTranscriptboundary; the production build rejects an initial entry larger than 750 KiB.
Graph/GraphRunfollow every pattern above: contracts schemas and transition tables,Context.Servicecapabilities, routes inApiRoutes, and an app-owned Postgres coordinator journal inapps/workerbehind theGraphCoordinatorJournalport.- Graph structure is validated only by
validateGraphin core; the editor surfaces API validation errors instead of re-implementing rules. - Node execution reuses the ordinary session/run machinery with
deterministic ids derived from
<graphRunId>/<nodeId>— coordinator replays are idempotent by construction. Do not invent a second dispatch path. - The graph transition tables live in
@repo/contractsbeside the status schemas (core re-exports them).graphRunMachinegenerates its STATUS transitions from the table — an illegal transition is inexpressible — andgraph-machines.test.tsexhaustively verifies allowed moves land and forbidden moves are dropped by the statechart. Callers never guard on machine state before sending; the machine decides.
- Unit tests use vitest with
Effect.runPromise(Effect.provide(program, TestLayer))and the deterministic doubles; no timing sleeps (why §12). Control time withTestClockfromeffect/testing(packages/core/test/clock.test.tsis the reference). - Postgres integration suites self-skip unless
DATABASE_URLis set; CI always runs them. pnpm guardrailsis the definition of done (why §14). Do not claim it passed without running it.
These are accepted gaps — do not "fix" them incidentally, and do not copy them into new code as precedent:
- Provider ports may later move onto
Context.Servicelayers for symmetric wiring withpackages/core(see decisions §2 for when). - The shared
@repo/node-httpbridge may later be replaced by@effect/platformHttpServer once it stabilizes (decisions §6). - The worker's one-second idle poll may later become an interruptible
JobQueue.awaitWorkbacked by PostgresLISTEN/NOTIFYplus a timeout fallback. That changes queue semantics and needs its own decision. - Workspace packages intentionally export source and share one root
typecheck. Independent emitted package builds require project references
and explicit output contracts; do not add no-op
buildscripts.