2026-09-23·by Sijie Wang#standmeet#architecture#design

backend-domain-modules

Backend domain modules — package by domain

Parent: structure · Repo doc: docs/design/backend-domain-modules.md

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.

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)
capabilitycapreg — boot plugins + owner-registered MCP / installed skillper-session cold-spawned sandbox
connectorconnector spec store — boot built-ins + owner-uploaded specHub (owner accounts + creds)

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

register a typemake an instance
connectorPOST /connectors (+/validate-spec) → Hub.Upsert uploaded spec/credentials + /connect + /activate
capabilityPOST /mcp-servers · marketplace InstallSkillper-session sandbox spawn (+ enable gate)

The two doors + where they touch

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; searchcorpus — it is corpus-shaped DAO (hardcoded corpus_notes index), not generic infra; mailerconnector; promptsowner; jobregistrystats.) 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; Hubconnection (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/obsidiancorpus/obsidian, usecases/report_*conversation/usecase, plugins/bookerowner/{entity,usecase}, plugins/ownercoreowner/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.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

backend-domain-modules