Backend domain modules — package by domain

Updated · View the entry on sijie.xyz ↗

The backend was packaged by layer, not by domain (until 2026-07-27). That was the root debt behind a whole class of tangles (a connector importing capabilities' god-package for its own concepts; a typed category surface the kernel isn't allowed to hold). This node fixes the target: package by domain — each domain owns its full vertical and exposes only a public facade.

The disease — three god-packages sliced by layer (cured 2026-07-27)

internal/usecases  (106)  ← every domain's usecase together   [dissolved]
internal/domain    ( 55)  ← every domain's entity together    [dissolved]
internal/postgres  ( 66)  ← every domain's repo together      [dissolved]

All three are gone, and internal/plugins (a fourth such bucket, for owner-side capability code) with them. internal/ now holds exactly the class-diagram set: the 8 core modules + capabilities

  • routes + infra. The fence runs with an empty baseline — pure-red, nothing grandfathered.
  • Reverse deps: connector/slots.go imports usecases (AgentToolConnector, ErrMailNotConfigured) — a domain reaching up for its own concepts because they have no home in the domain.
  • Typed category surface: contract.CalendarProxy is a compile-time Go interface — the "typed handle" capabilities' externalization forbids the kernel to hold.

Principle

Each domain = a self-contained module internal/<domain>/ owning entity + usecase + repo + public facade. Shared layers only for the genuinely domain-less. Controllers are only internal/routes/* (the real inbound layer); a module's "internal controller" is just its public facade.

The two plugin axes — one meta-structure

capabilities and connector are the same abstraction, differing only in call convention:

{ declaration (data) → implementation → instance → ONE opaque call door }
  • declaration = a data manifest that lives outside internal/ (backend/connectors/<id>/manifest.yaml and backend/capabilities/<id>/manifest.yaml, from disk or owner-registered) — category+verbs / capability+tools with schemas. Never a Go interface, never in internal/. The calendarVerbs/mailVerbs literals are gone, but as of 2026-09-07 contract.CalendarProxy / MailProxy (internal/connector/contract/contract.go) is still the typed category surface consumers program against — the un-migrated piece to extract into data.
  • implementation = the adapter/plugin satisfying a declaration.
  • instance = the runtime, scoped, invoke-able thing. Scope differs by axis: connector = owner-persistent (account + creds); capability = session-ephemeral (cold-spawned sandbox).
  • one opaque door per axis — name-keyed, opaque JSON, callee never sees caller.
classDiagram
  direction TB
  class CapabilityDeclaration { <<data manifest>> }
  class ConnectorDeclaration { <<data manifest>> }
  class CapabilityImpl { <<implementation>> }
  class ConnectorImpl { <<implementation>> }
  class CapabilityInstance { <<instance session-scoped>> }
  class ConnectorInstance { <<instance owner-scoped>> }
  class capreg { <<declaration registry>> }
  class connectorSpecStore { <<declaration registry>> }
  class perSessionBindings { <<instance registry>> }
  class Hub { <<instance registry>> }
  CapabilityDeclaration <|.. CapabilityImpl : implements
  ConnectorDeclaration <|.. ConnectorImpl : implements
  CapabilityImpl ..> CapabilityInstance : per-session spawn
  ConnectorImpl ..> ConnectorInstance : owner connect
  capreg o-- CapabilityDeclaration
  connectorSpecStore o-- ConnectorDeclaration
  perSessionBindings o-- CapabilityInstance
  Hub o-- ConnectorInstance

Two registries per axis

capreg is a declaration registry (types) — its counterpart is the connector spec store, not the Hub. The Hub is the connector instance registry, counterpart to the per-session capability bindings.

declaration registry (types) instance registry (runtime)
capability capreg — boot plugins + owner-registered MCP / installed skill per-session cold-spawned sandbox
connector connector spec store — boot built-ins + owner-uploaded spec Hub (owner accounts + creds)

Owner: a type path AND an instance path, both axes

register a type make an instance
connector POST /connectors (+/validate-spec) → Hub.Upsert uploaded spec /credentials + /connect + /activate
capability POST /mcp-servers · marketplace InstallSkill per-session sandbox spawn (+ enable gate)

The two doors + where they touch

flowchart TB
  agent["owner AI client / visitor agent"]
  mcph["routes/mcphandle"]
  conn["routes/connector"]
  capreg["capreg (capability decl registry)"]
  capInst["capability instance (booker sandbox)"]
  hub["connector Hub (instances)"]
  connInst["connector instance (gcal / fastmail)"]
  agent -- "tools/call(name, json)" --> mcph --> capreg --> capInst
  capInst -- "connector.invoke(cat, verb, json)  connector-deps" --> conn --> hub --> connInst

Capabilities sit above connectors; a capability instance reaches down through connector.invoke. The connector never asks capreg for anything. The only cross-axis edge is connector-deps.

Why category: self-hosted → each owner brings their own calendar/mail. A capability programs against a category (swappable interface); the owner binds the active provider. See connector.

Domain inventory

Core modules (internal/<domain>/, own entity+usecase+repo+facade):

  • corpus — raw/wiki/output/writing/note/tree/citation/subjectivity/crosslink/seo (+ its own corpus/search Meili subpackage — corpus-shaped DAO, not infra)
  • conversation — chat/dialog/message/ghost/visitor-session (+ inference as agent-core engine)
  • connector — connection/integration/mail_connector/connectorsvc + adapters (registry/door → platform axes)
  • access — access_code/access_request/role/role_snapshot/dock_buttons/api_key (+ session)
  • owner — owner/account/instance/page_content/microsite (renamed from custom_page, e7fe80e91, 2026-09-05; + microsite_store, the per-microsite persistence store, a94909aa9, 2026-09-04)/appearance/keypair/login/password/recovery + mail (mail_otp/outbound) + prompts + owner/jobs (the job loop)
  • security — captcha/banned_ip/login-guard/anti-replay (auth=access; protection=security)
  • marketplace — marketplace/skill/mcp_server
  • stats — stats_activity/growth/jobs / inference_usage / system_info

Capability axis (internal/capabilities/) — the mechanism by which standmeet's own agent loads/dispatches MCP capabilities. Sub-packages: capreg (declaration registry) · capsocket (host callback socket for sandboxed caps) · mcpclient/mcpplugin/mcputil (the client transport we use to dial owner-registered external MCP servers) · capstore (per-plugin isolated store) · capconfig (a plugin's declared owner-tunable fields, cc5c1db47, 2026-07-31) · capquota (per-code usage caps counted in the plugin's own store, aab8abe90, 2026-08-01) · sandbox (the docker run runner for owner skill scripts) · sandboxws (bwrap workspaces for MCP sandboxes). Generic loader ONLY — zero concrete capability: every concrete MCP (booker/retrieval/summarize/mail-sender/ask-visitor/…) is externalized (below), so reading capabilities you cannot tell any of them exist (enforced by the core-agnostic ratchet). It is not connector (that axis holds owner creds + reaches external services) and not the inbound MCP-server facade (routes/mcphandle, where external agents reach our tools). ghost is not a capability — it is conversation core.

Fully externalized capabilities (sandboxed, not core, live in top-level mcp-servers/): booker · retrieval · summarize · ask-visitor · mail-sender — five, matching the five backend/capabilities/<id>/manifest.yaml declarations (calendar.book, corpus.retrieval, summarize_conversation, ask_visitor, mail.send). There is no report server and never was (git log -- mcp-servers/report is empty); the report is summarize's chat_reports artifact. The one owner-side trusted cap still in-process is owner/jobs (the job loop); ownercore is gone (f35e82c04, 2026-08-02 — its caps became domain-declared ops, see owner-facade-from-registry).

Shared infra (domain-less, internal/infra/, as of 2026-09-07): pgstore (pgxpool + boot-time migrations, bd45353f4, 2026-08-31) · cryptobox · httpx · retry · storage · gotenberg · session · middleware · apierr · hostop · periodic · paritymanifest · facadeparity · providermodels · mailthrottle (per-recipient outbound throttle, 9d6d10d95, 2026-09-06) · snowflake (short ids, dbb803288, 2026-09-06) · buildnotify · clientaddr · depcheck · plaintext · selfstat · textcut. (sandbox/sandboxws and capsocket live under capabilities; config is cmd/server/config; search→corpus — it is corpus-shaped DAO (hardcoded corpus_notes index), not generic infra; mailer→connector; prompts→owner; jobregistry→stats.) Rule: concrete per-domain data access (SQL/index) belongs to the domain's repository; infra keeps only the genuinely domain-less base (the pgx pool, the http client, the crypto box), never a query/index shaped to one domain's rows.

Platform mechanism: the capability axis (capabilities, above) + connector registries · paritymanifest · facadeparity. (The plugins in-process loader is dissolved — fea7ae93f, 2026-07-29: its Plugin/Registry mechanism folded into capabilities, its implementations into owner/mcp-servers.)

Enforced structurally: infra/scripts/check-internal-dirs.sh fences internal/ to exactly {these 8 core modules · capabilities · routes · infra} — its ALLOWED list, eleven names, no usecases; any other sub-dir is a lint failure, and the shrink-only baseline file is gone (pure red). No un-diagrammed dir (e.g. plugins) may return.

What dies

  • The three by-layer god-packages (usecases/domain/postgres) — redistributed into modules.
  • contract.CalendarProxy / any typed category surface — declaration becomes data.
  • connector → usecases reverse dep — concepts move home.
  • Renames: capreg → capability declaration registry; Hub → connection (instance) registry; socket-op handlers → routes/<domain>/ controllers.

Externalization is not relocation

A capability is externalized only when the host keeps none of its logic. Moving the host copy to a tidier address inside internal/ passes every structural gate and changes nothing — the gates measure shape, and a semantic duplicate has a legal shape.

booker was the case: mcp-servers/booker/policy.go and the kernel's booking-policy evaluator were two implementations of the same rules (same conflict tokens, same slot constants), each file's header asserting the other owned it. Root cause was a mechanism gap — a sandboxed capability could only face visitors, so its owner-facing surface had to be reimplemented host-side. Fixed with mcpplugin.Manifest.OwnerTools: owner tools as declaration data, sandbox dialed on invocation.

The duplicate was not merely a drift risk: only the host binary imported time/tzdata, so once the evaluator ran in the sandbox every named IANA zone failed and list_slots returned an empty list — indistinguishable from "no availability". A duplicate hides which copy carries the environment the algorithm depends on. The copies also had different error conventions (isError vs {ok:false,error,detail}), so externalizing changed the owner-facing contract.

Paid (f614c08f3, 2026-07-31): the cancel cluster (uc_booking_cancel.go / uc_booking_cancel_own.go) duplicated the sandbox's deleteBooking; only the lookup differed (booking-id vs conversation+event-id). The owner-scoped calendar_cancel_booking tool now lives in mcp-servers/booker/main.go, the REST cancel path the card had stopped using was retired, and both host usecases are deleted.

Migration — connector pathfinder

  1. Done: connector.invoke controller → internal/routes/connector (thin shell), arch-locked.
  2. Declaration → data (drop contract.CalendarProxy as the type) — open as of 2026-09-07 (internal/connector/contract/contract.go still defines it).
  3. Done by construction (2026-07-27): usecases no longer exists, so the reverse dep cannot.
  4. Split registries: spec store (types) vs Hub (instances → connections) — open; hub.go still upserts both boot-time and owner-uploaded specs.
  5. Done (2026-07-27): replicated per domain; the three god-packages are dissolved, each slice green before the next. Last residues: usecases/obsidian→corpus/obsidian, usecases/report_*→conversation/usecase, plugins/booker→owner/{entity,usecase}, plugins/ownercore→owner/ownercore. Connector is the one core module still flat — steps 2-4 above are the open work.

Sub-modules are their own nodes. owner/jobs, corpus/obsidian and conversation/inference keep their own boundary (own entry points, not the domain's DDD guts); owner/ownercore was one too until it dissolved (f35e82c04, 2026-08-02 — the name still sits in backend/tools/archcheck/main.go:46's sub-module set, harmlessly, with no directory behind it). Both check-domain-facade-boundary and check-domain-acyclic treat that same set as separate nodes — otherwise an aggregator that legitimately spans domains (ownercore reached every domain's facade) would forge a false cycle onto the core it merely sits beside. Each domain's core must still be a clean node, and the acyclic gate still goes RED on a real core-to-core cycle.

Related: structure · capabilities · connector · key-designs.

Related notes