ADR-015 — One modules package (`@tedos/modules`), module manifests, and the connector entity
:::note Contenido en inglés Esta página del wiki se sincroniza desde la base de conocimiento en inglés y todavía no está traducida. :::
canonical· tech-lead · updated 2026-07-03 · source
- Status: Accepted (amended 2026-07-03 by José, #1154 — supersedes the same-day
@tedos/modules-<name>many-packages shape from #1121) - Date: 2026-07-03
- Owner: Tech Lead
- Related issues: #1121 (effort #1115) · #1154 (amendment); upcoming consumers #1129 (payments/points), #1131 (credentials)
Context
- Effort #1115 made
packages/core/src/modules/the canonical module registry: English module keys with Spanish labels/slugs, a legacy alias map, and the legacy-tolerantmodulesForgate. Console (#1117), comprender (#1118), and the API write path (#1119) consume it. - The next waves ship real module implementations — Stripe payments + points (#1129), credentials + student portal (#1131) — and need a decided home that respects ADR-012's app-import boundaries (no app consumes another app; shared code flows through packages).
- The first draft of this ADR (#1121) proposed one package per module
(
@tedos/modules-<name>). José corrected the shape the same day (#1154): one package family per module multiplies workspace entries, lockfile importers, and graph churn for no isolation gain — subpath exports already give per-module tree-shaking. - Modules will consume external services (WhatsApp, HubSpot, Stripe, …). Without a standardized connector concept, each module would grow its own integration — the exact per-module endpoint/identity zoo the CS-WhatsApp note below exists to prevent.
Decision
1. ONE modules package — packages/modules (@tedos/modules)
All module implementations live in a single workspace package with one folder + one subpath export per module:
packages/modules/
src/
payments/ → import from '@tedos/modules/payments'
points/ → import from '@tedos/modules/points'
credentials/ → import from '@tedos/modules/credentials'
- First-class package under ADR-012's one-way rules: apps import
@tedos/modules/<key>; the package never imports an app (nor a sibling by relative path —monorepo-turbo.md#1/#2). - Subpath imports are mandatory (never a root barrel across modules) — the D1 bundle rule is what keeps one package from becoming one bundle; each module's deps stay in its own import graph.
- One workspace entry, one lockfile importer, one
package.json— a new module is a folder + a subpath export, not a graph change (the lockfile rule stops applying per new module). - The registry in
@tedos/corestays the vocabulary + gate (MODULE_REGISTRY,normalizeModuleKeys,modulesFor);@tedos/modules/<key>carries the implementation behind its registry key. Generic UI stays in@tedos/ui; cross-module shared logic stays in@tedos/core— a module folder is for module-specific implementation only.
2. The configuration-file rule — every module ships a manifest
Each module folder carries a typed manifest (module.config.ts) as its single entry contract:
// packages/modules/src/payments/module.config.ts
import type { ModuleKey } from '@tedos/core/modules'
import type { ConnectorKey } from '@tedos/core/connectors'
export default {
key: 'payments' satisfies ModuleKey, // MUST be a canonical registry key
requiredConnectors: ['stripe'] satisfies ConnectorKey[],
// jobs / routes / schema hooks the host mounts, declared not hardcoded
} satisfies ModuleManifest
keyis bound to the canonicalModuleKey— a module folder without a registry key (or a registry key without a folder once implemented) is lint-detectable drift.- The manifest declares what the module needs (connectors, jobs, routes); the host (apps/api, apps) mounts it. Modules never self-wire into an app.
- The manifest shape (
ModuleManifest) lives in@tedos/corenext to the registry, so the vocabulary and the contract evolve in one place.
3. The connector entity — same pattern as modules
External integrations (WhatsApp, HubSpot, Stripe, Google, …) are connectors, standardized by mirroring the module-registry pattern exactly:
CONNECTOR_REGISTRYin@tedos/core/connectors— canonicalConnectorKeyvocabulary +ConnectorDefinition(key, label, capability, config descriptor). Like modules: core owns the vocabulary, never the implementation.- Implementations stay tenant-aware in
packages/engine/src/connectors/(workflow-vs-product rule: tenant connectors — OAuth, tokens, client accounts — are PRODUCT, homed in the engine). - Modules declare
requiredConnectors: ConnectorKey[]in their manifest and receive connector clients by injection from the host at runtime — a module never imports the engine. This keeps modules testable (inject a fake connector) and keeps the engine the single place secrets and OAuth live. - One connector per service, shared by every module that needs it — e.g. the future CS module and
a marketing module both consume THE
whatsappconnector, never a second WhatsApp integration.
Accepted duplication — the API alias mirror (#1119)
apps/api/src/routes/config/module-keys.ts is a documented MIRROR of the registry's canonical
keys + legacy alias map, with identical semantics (normalize, deny unknown, dedupe first-wins).
Accepted because @tedos/core is a React/Next-stack package — far too heavy a graph for the Fastify
backend. The registry is the source of truth; any key/alias change lands in registry.ts first
and is replicated to the mirror in the same PR.
Forward compatibility — CS-WhatsApp (direction, not spec)
So the future customer-service/WhatsApp module plugs in without schema rework:
- ONE WhatsApp connector — a single
whatsappentry in the connector registry; the CS module consumes it viarequiredConnectors, never a second WhatsApp integration. - A generic webhook entry — one inbound webhook surface in the API that routes by connector/event, not a per-module endpoint zoo.
- A phone-identity seam — person ↔ phone resolution as an explicit seam, so inbound messages attach to the existing person/channel model rather than a module-private identity table.
Consequences
Positive: implementations get ONE decided home before #1129/#1131 land; a new module is a
folder, not a workspace-graph change; the manifest rule makes module↔registry drift lintable;
connectors become a first-class, injectable, shared entity — the CS-WhatsApp and HubSpot futures
slot in without new architecture.
Negative / trade-offs: the alias mirror is manual sync (same-PR rule, small surface); a single
package demands discipline on subpath imports (D1) so module deps don't bleed across modules;
connector injection adds one seam of indirection between module and engine.
Follow-ups: first concrete module @tedos/modules/payments (#1149); ModuleManifest +
@tedos/core/connectors registry land with it; a lint for folder↔manifest↔registry consistency;
revisit a framework-light key/alias micro-package if the api mirror ever grows beyond keys+aliases.
Alternatives considered
| Option | Why not |
|---|---|
One package per module (@tedos/modules-<name>) | The #1121 draft. Multiplies workspace entries, lockfile importers, and graph-change PRs per module; subpath exports already isolate bundles. Rejected by José (#1154). |
Implement modules inside @tedos/core | Bloats the framework package; every app pulls every module's deps (Stripe, etc.). |
| Implement modules inside an app (e.g. admin) | Recreates app→app reach the moment a second app needs the module — exactly what ADR-012 bans. |
| Modules import engine connectors directly | Couples modules to the engine's tenant/auth internals and makes them untestable without it; injection keeps the boundary. |
API imports @tedos/core (no mirror) | Drags the React/Next graph into Fastify; the mirror is the lighter documented cost. |
References
- Rule:
.claude/rules/app-import-boundaries.md(§ Module packages) · ADR-012 (the boundary this extends) - Registry:
packages/core/src/modules/registry.ts· Mirror:apps/api/src/routes/config/module-keys.ts - Connectors (implementation home):
packages/engine/src/connectors/·.claude/rules/workflow-vs-product.md - ADR-010 (
@tedos/coreframework) ·decisions/log.md(2026-07-03)