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.goimportsusecases(AgentToolConnector,ErrMailNotConfigured) — a domain reaching up for its own concepts because they have no home in the domain. - Typed category surface:
contract.CalendarProxyis 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.yamlandbackend/capabilities/<id>/manifest.yaml, from disk or owner-registered) — category+verbs / capability+tools with schemas. Never a Go interface, never ininternal/. ThecalendarVerbs/mailVerbsliterals are gone, but as of 2026-09-07contract.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) | |
|---|---|---|
| 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
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/searchMeili 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 → usecasesreverse 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
- Done:
connector.invokecontroller →internal/routes/connector(thin shell), arch-locked. - Declaration → data (drop
contract.CalendarProxyas the type) — open as of 2026-09-07 (internal/connector/contract/contract.gostill defines it). - Done by construction (2026-07-27):
usecasesno longer exists, so the reverse dep cannot. - Split registries: spec store (types) vs
Hub(instances → connections) — open;hub.gostill upserts both boot-time and owner-uploaded specs. - 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.