Skip to content

Latest commit

 

History

History
216 lines (183 loc) · 10.5 KB

File metadata and controls

216 lines (183 loc) · 10.5 KB

Canonical Implementation Patterns

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.

Derive, don't guard

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 === 0 via the isTerminal*Status helpers — never an inline status === "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.

Package anatomy

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 Layer lives in internal/<name>-live.ts and is exported through live.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 through AppConfig (AI_PROVIDER=fake, SANDBOX_PROVIDER=fake). They ship from the public barrel on purpose.

Domain services (packages/core)

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 in errors.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's internal/*-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; normalize Date columns with normalizeTimestamps from internal/sql-helpers.ts instead of writing a new inline converter.
  • Live layers take time from the Effect Clock (why §10) — use nowTimestamp from internal/sql-helpers.ts (core) or a local Clock.currentTimeMillis mapping — never new 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.

Provider ports and adapters

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:

  1. The port package owns the interface, repository schemas, and one tagged error union with an operation, reason, and retryable field.
  2. 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).
  3. Decode every SDK response with a repository schema before it crosses the port boundary. No SDK type appears in a port signature (review).
  4. Map SDK failures into the port's tagged error and preserve the distinguishing reason; do not collapse everything to unavailable.
  5. Ship a deterministic double next to the port (test.ts) that implements the same interface without processes or network.

HTTP surface

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:

  1. Add request/response schemas to the owning packages/contracts module.
  2. Add the route to ApiRoutes — the server now fails to compile until a handler exists in apps/server/src/api.ts (RouteHandlers is keyed by RouteName).
  3. Write the handler: it receives schema-decoded params and body.
  4. Add a client method in packages/client/src/client.ts using buildPath(ApiRoutes.<name>, params) and the route's schemas, and expose it through the Promise facade (promise.ts).
  5. Add an errorStatus entry in apps/server/src/api.ts for every new tagged error the handler can surface. Unknown tags intentionally become 500.
  6. Add a query/mutation option factory in packages/client-react when 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.

Configuration

  • process.env is read only in packages/config and app main.ts entrypoints (enforced).
  • decodeAppConfig throws on invalid boot configuration by design (why §7): a config error must kill the process before any listener starts. Everything after boot receives the typed AppConfig value.

Frontend state

  • TanStack Query owns remote state; query keys and option factories live in packages/client-react. An unavailable branded ID is represented by undefined plus skipToken, never a fabricated empty ID.
  • XState owns only real workflows (active run, approval, reconnect).
  • Base UI is imported only inside packages/ui; radix-ui/cmdk only inside the vendored apps/web/src/components/ui/ directory or packages/ui (enforced).
  • Visual tokens come from apps/web/DESIGN.md (why §15), are declared as Tailwind @theme colors in src/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), and src/design-tokens.test.ts fails 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 lazy RunTranscript boundary; the production build rejects an initial entry larger than 750 KiB.

Agent graphs

  • Graph/GraphRun follow every pattern above: contracts schemas and transition tables, Context.Service capabilities, routes in ApiRoutes, and an app-owned Postgres coordinator journal in apps/worker behind the GraphCoordinatorJournal port.
  • Graph structure is validated only by validateGraph in 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/contracts beside the status schemas (core re-exports them). graphRunMachine generates its STATUS transitions from the table — an illegal transition is inexpressible — and graph-machines.test.ts exhaustively verifies allowed moves land and forbidden moves are dropped by the statechart. Callers never guard on machine state before sending; the machine decides.

Testing

  • Unit tests use vitest with Effect.runPromise(Effect.provide(program, TestLayer)) and the deterministic doubles; no timing sleeps (why §12). Control time with TestClock from effect/testing (packages/core/test/clock.test.ts is the reference).
  • Postgres integration suites self-skip unless DATABASE_URL is set; CI always runs them.
  • pnpm guardrails is the definition of done (why §14). Do not claim it passed without running it.

Known deferred work

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.Service layers for symmetric wiring with packages/core (see decisions §2 for when).
  • The shared @repo/node-http bridge may later be replaced by @effect/platform HttpServer once it stabilizes (decisions §6).
  • The worker's one-second idle poll may later become an interruptible JobQueue.awaitWork backed by Postgres LISTEN/NOTIFY plus 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 build scripts.