Skip to main content

ADR-015 — One modules package (`@tedos/modules`), module manifests, and the connector entity

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-tolerant modulesFor gate. 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/core stays 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
  • key is bound to the canonical ModuleKey — 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/core next 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_REGISTRY in @tedos/core/connectors — canonical ConnectorKey vocabulary + 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 whatsapp connector, 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 whatsapp entry in the connector registry; the CS module consumes it via requiredConnectors, 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

OptionWhy 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/coreBloats 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 directlyCouples 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/core framework) · decisions/log.md (2026-07-03)