| name | domain-backend |
| description | Use when designing Effect HTTP API surfaces (HttpApi, HttpApiClient, branded params, typed errors), Effect Config / AppConfig, module boundaries (Domain/Errors/Api/Service/Repo), sub-modules, Postgres persistence, or reviewing backend layout in apps/api or packages/api-core. Prefer repos/effect-smol and docs/agent-patterns/ for Effect idioms. NOT for visual UI. |
Effect Config (AppConfig)
- Non-secret knobs live in
apps/api/src/Infrastructure/AppConfig.ts via Config.all + withDefault (PORT, API_HOST, OPENROUTER_MODEL, triage pacing).
- Secrets:
Config.redacted on layerConfig surfaces (DATABASE_URL, OPENROUTER_API_KEY).
- Demo-mode gate stays raw dynamic
process.env in runtime-mode.ts (Next bundling).
- Tests: prefer
ConfigProvider.layer(ConfigProvider.fromUnknown(…)) when Effect Config is SoT.
- See
repos/effect-smol/packages/effect/CONFIG.md and docs/agent-patterns/effect-config.md.
SQL row → domain (Effect Schema)
Repos must not hand-map Postgres rows with as casts or new Domain({ … row.email_id … }).
Decode through Effect Schema at the repo boundary:
- Start from the api-core domain schema (
Schema.Class / Schema.Struct).
- Remap snake_case columns with
Schema.encodeKeys({ emailId: 'email_id', … }).
- Decode with
decodeSqlRow from apps/api/src/Infrastructure/Database/DecodeSqlRow.ts (wraps Schema.decodeUnknownSync + optional TEXT→JSON parse).
Canonical example (see Decisions / Actions / Runs / Chat repos):
const DecisionFromRow = Decision.pipe(
Schema.encodeKeys({
emailId: 'email_id',
whyPreview: 'why_preview',
keyFacts: 'key_facts',
isSensitive: 'is_sensitive',
policyReasons: 'policy_reasons'
})
)
const decodeDecision = decodeSqlRow(DecisionFromRow)
SQL NULL vs Schema.optional
Postgres returns JS null. Bare Schema.optional(X) expects the key absent / undefined,
not null. For nullable columns use:
Schema.NullOr(X) when the domain value is X | null
Schema.optional(Schema.NullOr(X)) when both omit and SQL NULL are allowed
Do not strip nulls in a generic row transform before decode — that breaks NullOr fields
that require the key to be present as null.
Sync decode exception
decodeUnknownSync inside repos via decodeSqlRow is the intended pattern.
Do not use sync Schema decode in HTTP handlers or agent loops — keep those Effect-shaped.
HttpApi handlers
HttpApiBuilder.group(Api, 'group', …) then handlers.handle('endpoint', …).
Effect.fail declared endpoint errors; do not Effect.die / orDie domain errors that belong in OpenAPI.
- Infra failures (
ConfigError, AiError) may stay defects (500) unless declared as typed 5xx schemas.
- Never
Schema.decodeUnknownSync path/body ids in handlers — endpoint params already decoded.
- See
docs/agent-patterns/effect-httpapi.md.
Append-only ledger exception
apps/api/src/Modules/Actions/Repo.ts is not an upsert aggregate. Effects are immutable rows: use append
(+ get/list/delete for wipe). Idempotency is (run_id, action, action_revision)
at the DB layer, not an in-place update of a prior row.
Backend Surface Design (agentic-inbox)
Principles for what to expose on the Effect API and how to structure backend
modules in this shared-inbox workspace.
For TypeScript quality see practice-code-quality. For UI product design see
domain-design. For React/Next implementation see domain-frontend.
Full file-split checklist: module-layout.md.
Product constraints (backend-relevant)
- Sensitive emails must never be auto-actioned.
- Every agent action must be legible in plain language per email.
- Wrong calls must be cheaply reversible (undo / re-triage).
- The email dataset is static —
data/emails.json, ids e-001..e-080.
Before you design
- Match existing modules — Domain / Errors / Api / Service / Repo split.
- Prefer extending
@app/api-core schemas before inventing parallel types.
- Compose HTTP layers in
apps/api/src/Modules/Layers.ts (CoreModulesLive).
- Before writing Effect code, read
repos/effect-smol/LLMS.md, then docs/agent-patterns/.
Codebase anchors
| Concern | Path |
|---|
| HTTP contracts | packages/api-core/src/Modules/ (@app/api-core) |
| Runtime modules | apps/api/src/Modules/ |
| Layer merge | apps/api/src/Modules/Layers.ts |
| Postgres migrator | apps/api/src/Infrastructure/Database/ |
| Vendored Effect | repos/effect-smol/ (read-only; see repos/README.md) |
| Agent pattern files | docs/agent-patterns/ |
Quick reference
- Schemas in api-core; handlers/repos in
apps/api.
- Mutable repos: whole-entity
upsert; no per-field writers.
- Second aggregate → sub-module folder on both packages.
- Default answer to new public routes is no — extend existing groups.
- Never auto-action sensitive mail.
- Do not import from
repos/; prefer it over web search for Effect idioms.
Repo surface (mutable aggregates)
Persist whole entities. Services mutate in memory, then call upsert / create.
Do not add per-field writers (updateStatus, setPending, complete, …).
Canonical shape (see apps/api/src/Modules/Triage/Decisions/Repo.ts):
upsert / create
get / list*
deleteByEmail / deleteAll (demo wipe)
Append-only stores (action ledger) use append instead of upsert.
Atomic claim/CAS methods are OK when concurrency requires them — not as a substitute for upsert.
Sub-modules
When a module needs a second Domain/Repo aggregate, nest it:
Modules/Triage/Decisions/, Modules/Triage/Runs/ — mirror in packages/api-core
(Triage/Runs/Domain.ts). Prefer that over a second flat Repo.ts at the parent.
Example: Schema row decode in apps/api/src/Modules/Triage/Decisions/Repo.ts
DecisionFromRow = Decision.pipe(Schema.encodeKeys({…})) then
decodeSqlRow(DecisionFromRow) — see also Infrastructure/Database/DecodeSqlRow.ts.
Example: Triage runs sub-module
apps/api/src/Modules/Triage/Runs holds the runs aggregate. Repo methods are whole-entity only
(create / upsert / get / optional listByEmail / wipe deletes).
Status and pending transitions belong in TriageService / the engine, which
build an updated TriageRun and upsert — never updateStatus on the repo.
Public HTTP stays intent-shaped (run triage, resume by runId), not CRUD on runs.
Web HttpApiClient
- Prefer
HttpApiClient.make(Api, { baseUrl }) + FetchHttpClient.layer for JSON routes.
- Import wire types from
@app/api-core (see lib/inbox/types.ts); do not redefine mirrors.
- SSE (triage/chat) may keep a thin raw-fetch adapter for AbortSignal + UI event narrowing.
- See
docs/agent-patterns/effect-httpapi.md.
HttpApi contracts (packages/api-core)
- One
HttpApi composed of groups/endpoints; handlers live in apps/api.
- Path params use branded domain ids (
EmailId, ApprovalId, LedgerEntryId), not bare Schema.String.
- Declare every handler
Effect.fail error on the endpoint with httpApiStatus.
- Annotate endpoints (
OpenApi.Summary / Description) and shared models (identifier).
- API-level
SchemaErrorHandler maps decode failures to structured 422s.
- When unsure, read
repos/effect-smol/packages/effect/HTTPAPI.md and docs/agent-patterns/effect-httpapi.md.
- Do not import application code from
repos/.
api-core sub-modules
Extra aggregates under a parent module live in a subfolder (packages/api-core/src/Modules/Triage/Runs):
Domain.ts here, runtime Repo.ts / services under the matching
apps/api/src/Modules/Triage/Runs/ path. Keep HttpApi groups at the parent
unless the sub-module is independently HTTP-exposed.
Batch HTTP TriageRunRequest stays on the parent Triage/Domain.ts.