← All decisions

Platform extensions: opt-in features that live outside ark's core

accepted

0023 — Platform extensions: opt-in features that live outside ark’s core

  • Status: accepted
  • Date: 2026-05-21
  • Deciders: Derek

Context

Some ark features are universal — every tenant has organizations, members, profiles, invitations, branding, pages, audit, forum, and CMS. Without these you don’t have an ark tenant. They are core.

Other features are specialized — a timesheet for time tracking, an events system for arts schools, a billing/invoicing module for consulting tenants. Most tenants won’t use any given specialized feature; some tenants will use many. Today every such feature has been wired manually into five files (admin app routes, admin nav, API router mounts, audit-verb constants, tenant config). That works for the first three features and turns into a tax around the sixth.

The work in front of us — porting the internalize timesheet — is the right moment to draw the line:

  • Core is the universal substrate. It stays as is.
  • Extensions are opt-in, per-tenant, settings-rich features. They follow a declarative contract that makes adding one a single-PR exercise instead of a five-file scavenger hunt.

This ADR records the contract. The timesheet is the first extension built against it.

Decision

Introduce a platform extension abstraction. An extension is:

  1. A first-party @ark/<slug> package that owns its data layer (DB tables, Zod schemas), its server logic, and its UI surface.
  2. Gated by a boolean flag in the tenant config at modules.<slug>. The existing modules: { ... } pattern documented in @ark/tenant-config/CLAUDE.md is the canonical gate; we do not introduce a parallel mechanism.
  3. Configured through a typed settings object at the tenant-config top level (tenant.<slug>), mirroring the existing cms: { contentTypes } convention. The settings shape is a Zod schema exported by the extension’s package, mounted onto TenantConfigSchema as an optional field.
  4. Self-described by an ExtensionManifest value exported from the package. The manifest declares the slug, label, settings schema, and audit verbs. Future fields (API router factory, admin/member routes + nav) land in subpath modules (@ark/extensions/server, @ark/extensions/admin) as the consuming surfaces materialize — keeping client bundles free of server types.
  5. Composed statically. There is no dynamic discovery, no manifest scanning, no plugin loader. Extensions are imported by name in central registry files. Composition happens at TypeScript compile time.

Existing features (forum, CMS, Google Calendar, notifications, email, images) are not migrated in this ADR. They can opt in later when the cost of staying out grows. The migration is mechanical: declare a manifest, route through the registry.

Why this is justified now, not earlier

The third-appearance rule from ADR 0011 says: don’t extract an abstraction until you’ve seen the pattern twice. By the count of feature packages with their own data layer + API + UI + audit verbs, we are at the sixth or seventh appearance. The pattern is long past justified; what was missing is a name and a contract.

The timesheet is also the first feature whose settings are richer than any prior: punch vs manual modes, approval workflow, rounding rules, target periods, seeded categories. Without a typed settings schema, the timesheet would either:

  • Hardcode settings in code (breaks the “configuration over code forks” tenet from ADR 0004), or
  • Sprawl across the tenant config as ad-hoc fields with no Zod backing.

Neither is acceptable; a settings-schema contract is required regardless. The extension manifest is the minimum surface that makes the settings contract first-class.

Boundaries

  • Core stays core. The line between core and extension is “would a sensible tenant turn this off?” Forum, CMS, branding — no. Timesheet, billing module, events module — yes.
  • No third-party extensions. Every extension is a first-party @ark/* package in this monorepo. We are not building a plugin marketplace.
  • No runtime discovery. The registry is explicit imports in one central file. A typo doesn’t ship a half-broken extension — it fails to compile.
  • Settings are validated at load time. A tenant config with an invalid tenant.<slug> block fails the same way invalid cms.contentTypes fails today: loudly, at import.

Consequences

  • Positive. New specialized features land in one PR. Settings get Zod typing. The “what does this tenant actually have enabled” question is answerable from one file. The contract documents itself.
  • Positive. Per-extension audit verbs are registered through the manifest, so the verb taxonomy (see ADR 0020) stays consistent across the codebase.
  • Negative. One more layer of indirection to learn. Mitigation: the manifest is a plain TypeScript object — no class hierarchy, no decorators, no metaprogramming. If you’ve seen a Zod schema you’ve seen the hard part.
  • Negative. Existing features (forum, CMS, etc.) split the codebase between “extension-style” and “manually-wired”. Mitigation: migrate them in a follow-on refactor PR (planned as PR-J in the timesheet workstream). The migration is mechanical and reviewer-friendly.

What “an extension” gets you, concretely

For a tenant admin:

  • A boolean flag they (or their installer) can set in tenant config to turn the extension on or off — no code change.
  • Settings their admin or installer can tune — categories, modes, validation, approval, etc.
  • The same audit, RLS, and multi-tenancy guarantees that core features carry. An extension cannot “escape” the org boundary; it inherits the same organization_id discipline.

For ark itself:

  • A clear inventory of what the platform offers (@ark/timesheet, @ark/events when it lands, etc.).
  • A consistent shape across features — easier to onboard new contributors, easier for AI agents to land non-trivial changes (ADR 0011’s AI-iterability tenet).
  • A natural extension point for future tenants who pay for, or contribute, specialized features.

First consumer

The timesheet (@ark/timesheet) is the first extension. Its design lives in docs/superpowers/specs/2026-05-21-platform-extensions-and-timesheet-design.md. Migration 037 adds the data layer; the audit verbs (timesheet.*) are registered in the manifest now even though the API routes that emit them land in later PRs.