---
name: platform-extract-modules
description: "Port existing functionality from prior production apps into versioned, headless, swappable @platform-modules/* packages. Use when capability duplicated across prior apps and want to extract into shared module, when asking 'is X good module candidate', when designing adapter/capability/domain boundaries, or on /platform-extract-modules. Module-porting assembly line: survey → disposition → call-site audit → classify → seam design → scaffold → migrate → harness → publish. For NET-NEW module with zero prior apps, use /platform-make-modules instead — scan-gates first, routes back here moment a prior app has it."
---

# Extract Modules — Module-Porting Pipeline

**Audience: AI coding agents first.** Optimize for activation, not prose — keep ladders as ladders.

Turn functionality rewritten across many prior apps into **versioned, headless, swappable packages**. One `platform/` monorepo builds them; any project installs only what need. (Net-new, zero-reference module? → `/platform-make-modules`, scan-gates and routes here when a prior app turns up.)

## House assumptions (this codebase)

- Target runtime: **Cloudflare Workers** (hard ~1–3 MB compressed bundle limit — bundle size real constraint).
- House stack: **Astro + React + Drizzle + CF**. Some projects React-only / Hono / React-Router. Rust/Python/CLI/audio projects **out of scope** (nothing swappable there).
- Server parts MUST build on **web-standard `Request`/`Response`** so same module runs under Astro endpoints, Next routes, Hono, CF Workers, no per-framework rewrite. **Do not force projects migrate frameworks.**
- ORM is **Drizzle**; module owns own schema, travels with own migrations.

## Coding standard (BLOCKING — load before any implementation)

**Single source of truth: `docs/standards/coding-standard.md`.** Don't restate rules here — load that doc before writing or migrating any code. This is *porting* skill, so four load-bearing sections:

- **§2 — implementation ladder.** Climb to first rung that holds (YAGNI / adoption-count: no 2nd consumer ⇒ don't build). Hard floor never cut: trust-boundary validation, data-loss handling, security, accessibility.
- **§3 — R1/R2/R3 porting hazards** — exact things prior-app code smuggles into seam: **R1** strip source-app defaults · **R2** collapse identity ceremony · **R3** memoize expensive natives. Non-optional for this skill; scan the source for all three before designing.
- **§4 — boundary checklist** — every public export satisfies all boxes before "done" (separate layer from §5 consumer-harness fixture; unit-at-seam + integration-fixture both required).
- **§5 — porting from prior apps** — source→ladder→strip→collapse→seam→test pipeline; ×1 = WATCH **unless forward-need fires** (CLAUDE.md §3: foundational + broadly-demanded across future app classes → buildable at ×1), convergence must be structural not superficial.

## 1. Candidate rubric — is this a good module?

Score functionality against these. High across all = extract it.

1. **Bounded context** — one named concern you'd say out loud ("payments", "newsletter"). Can't draw box → not a module.
2. **Narrow, stable interface** — few things in, few out, rarely changes.
3. **Owns own data** — own Drizzle tables; never reaches into host's domain tables.
4. **Low coupling to host UI/domain** — doesn't need know product (music vs forum vs shop).
5. **External calls isolatable** — talks to third parties (Stripe, Twilio…) through seam that can be swapped.
6. **High reuse × duplication pain** — rewritten 3+ times. Max token/time savings. **OR forward-need** (CLAUDE.md §3 disposition): *foundational* capability broadly demanded across future app **classes** being built earns build at ×1 (even ×0 prior apps), provided delete-test + same-seam + YAGNI guards hold. Adoption count is floor, not ceiling — platform seeded for many future apps, not backward dedup of today's reference apps.
7. **Swappable without touching callers** — replace impl, callers don't change. Whole point.

**The delete test (keystone gate — apply before committing to any module):** *If delete this module, does complexity vanish — or just move back into N apps?* Name what duplicated code disappears from consuming apps, and what irreducibly host-domain and can't move in. Complexity genuinely centralizes → real module. Complexity merely **relocates** while adding new dependency → NOT a module: dissolve into `@platform-modules/util` leaves + documented pattern, or keep in host. This killed `@platform-modules/cron`, `cache/http`, and `@platform-modules/kv-store`; confirmed `@platform-modules/search` as *thin* seam (federation recurs everywhere, per-entity SQL stays host). A "no" here is success, not failure.

Rank candidates; **pilot highest-value, clearest-boundary one first** (payments standing pilot). Don't build whole platform at once — each module own decompose→spec→build sub-project. Every module gets plan from `platform/docs/plans/module-plan-template.md`, which bakes in delete test, adapter minimalism (§3), consumer-harness registration (§5) as pass-gates.

## 2. The three axes of variation — classify everything before designing

Single most important design step. For every piece of variability, decide which axis it is:

| Axis | Definition | Packaging | Example |
|------|-----------|-----------|---------|
| **Adapter** | *different external service doing same job* | **separate package** (drags heavy 3rd-party SDK) | stripe vs morning vs payplus |
| **Capability** | *optional feature one service may or may not offer* | **opt-in subpath** (own logic, tree-shakeable) | authorize+capture (J5/J2), installments, refund |
| **Domain** | *your product's specific composition* of capabilities | **stays in host project** (or own domain module) | group-buy escrow, marketplace splits, dunning |

**Decision rule:**
- Different external service, same job → **adapter** (separate package).
- Optional feature within service → **capability** (opt-in subpath).
- Product's specific logic → **domain** — keep OUT of shared module, or stops being reusable.

If tempted to push product logic (e.g. "groups", "deals") into shared module — stop. Module exposes generic capabilities; project composes them.

## 3. Packaging rule (HARD) — core = seam only; everything else opt-in

**Core (`@you/<module>`)** — always bundled, tiny, mandatory. Contains *only engine*:
- interface/contract (types)
- service/registry that selects & wires adapters at runtime
- Drizzle schema + types
- error handling

No business capability in core. (Mirrors Auth.js: core is just framework; every provider/feature opt-in.)

**Capabilities** — every feature is **opt-in subpath export written as standalone functions** (NOT methods on fat object — methods on imported object not tree-shaken; standalone function exports are). Import what use; rest unreachable, never bundled.

```ts
// @you/payments               core engine + types + registry
// @you/payments-stripe         adapter package (+ `stripe` SDK)
//   ├ /ops      → export function charge(ctx,args), refund(ctx,args)
//   └ /holds    → export function authorize(ctx,args), capture(ctx,args), voidAuth(ctx,args)

import { createPayments } from '@you/payments'
import { stripe } from '@you/payments-stripe'        // thin client: holds SDK + config + capability manifest
import { charge } from '@you/payments-stripe/ops'    // ONLY charge + thin client bundled; refund/holds tree-shaken

const ctx = stripe(env)
await charge(ctx, { amount, currency, ... })
```

**Two refinements (precision, not new criteria):**
1. **Subpath per *cohesive group*, not per function.** `authorize`/`capture`/`void` ship together (`/holds`) — capture meaningless without authorize. Group what changes together.
2. **Adapters are separate *packages*, not subpaths** — only exception, for **dependency isolation**: each adapter drags heavy foreign SDK; separate packages mean don't install/ship SDK you don't use, can audit/pin each independently. *Own logic* = subpath. *Foreign SDK* = package.

**Adapter minimalism (HARD) — adapter starts as ONE direct file/function.** `core` = types + policy + orchestration seam. `adapter` = **direct provider/runtime call**, nothing more. **No internal Service / Repository / Controller / Provider-Client indirection** unless adapter has *real multi-step behavior* that earns it. Default to one function; if write a layer, justify in one line.

```ts
// ✅ start here
export function createResendAdapter(env) { /* holds client + config */ }   // -> resend.emails.send(...)
// ❌ never start here
// MailController -> MailService -> MailProvider -> ResendClient
```

**The two jobs of capability metadata (don't conflate):**
- Thin client exports `capabilities` manifest (e.g. `Set(['charge','authorize_capture'])`) → **runtime** check: core errors clearly if project asks adapter to do what can't (esp. when provider chosen dynamically per-tenant).
- import / don't-import → **build-time**: is this code shipped at all.

## 4. Publishing

> Note: "publishing" = how module **packages** are distributed (npm/GitHub Packages). NOT the `distribution` *delivery tier* (a themed installable product over a blueprint — see `registry.json → deliveryStages` + delivery-stack §2). Different concept; kept distinct to avoid the word collision.

- One **`platform/` turbo monorepo** holds every module + adapter package. Develop with workspace links (edit live, no publish).
- Publish each package to **GitHub Packages** (hosted by GitHub, free for own packages, ~zero ops — NOT self-hosted Verdaccio). Version with **changesets** (per-package semver, tag, publish).
- Projects consume via `pnpm add @you/payments @you/payments-stripe`.
- **git-dependency** (`"@you/x": "github:you/platform#x-v1.2.0"`) is zero-publish fallback, but note: git-dep installs whole repo root, so suits one-repo-per-package, not monorepo subpath. Prefer GitHub Packages for monorepo.
- Swapping version = bump dep, `pnpm i`. Each project upgrades on own schedule. Module source lives ONLY in `platform/` — never edit module inside consuming project (link for dev instead).

## 5. Host integration contract

Consuming project needs **no special structure** — just satisfy three things:

1. **Register schema** — `export * from '@you/<module>/schema'` into project's Drizzle migration set.
2. **Mount handler** — one file, web-standard handler:
   ```ts
   // Astro: src/pages/api/<module>/[...path].ts
   import { handler } from '@you/<module>/server'
   export const ALL = (ctx) => handler(ctx.request)
   ```
3. **Provide config + secrets** — `createX({ provider, credentials, db })`. Module NEVER owns secrets; host passes them (encrypted at rest). This why one module serves many projects with different accounts.

UI optional: ship unstyled `@you/<module>-react` kit, or let project build own against typed client. Never ship styled pages from module (breaks across stacks).

**Consumer-harness registration (HARD — every shipped module).** Each module MUST register fixture in existing `apps/consumer` app (module gallery + integration testbed — spec `platform/docs/specs/2026-06-13-consumer-harness.md`; already ships `@platform-modules/i18n` harness) proving **real** consumption — module-namespaced page(s)/island(s) for visual + Playwright/assertion spec for behavioral + `registry.json` manifest row, enforced by coverage-assertion test: visual (i18n renders pages, auth shows login→session→protected-route), behavioral (mail shows captured fake sends, jobs runs in-memory queue, audit writes+lists, cache hit/miss+single-flight), usually both. Must catch broken exports/types/runtime-integration, exercise at least one failure/edge path — not happy-path demo. Harness stays design-system page: small, concrete, ruthless — never demo product. Module not `shipped` until fixture registered, green, visibly turns red when export broken. This is standing-testbed form of §6 step 7.

## 6. Extraction process (per module — the checklist)

Create task per step; do in order.

1. **Confirm candidate** against rubric (§1).
2. **Audit real call-sites** across ALL projects that use it (read code, not keyword scores). Build matrix: which capabilities each project actually uses, which project-specific (domain), which providers appear. Mandatory before designing — defines core vs capability vs domain empirically.
3. **Classify** every behavior into adapter / capability / domain (§2).
4. **Design interfaces** in **function model** (§3): thin-client shape, capability function signatures, result/error types, Drizzle schema. Pick *most mature existing impl* as base (verify maturity by reading, don't assume). (→ load §2 ladder now; scan source for R1/R2/R3 hazards before designing)
5. **Scaffold** `@you/<module>` (core) + `@you/<module>-<provider>` adapter packages with capability subpaths.
6. **Migrate** chosen source impl into packages; strip domain logic back out to host. (→ climb §2 ladder; strip R1 source-app defaults; collapse R2 identity ceremony; memoize R3 natives)
   **§4 gate:** run boundary checklist (§ "Coding standard" above) on every public export. All boxes must hold before advancing.
7. **Verify on one pilot consumer** — wire into most demanding project, run it, confirm behavior (not just types).
8. **Register `apps/consumer` fixture** (§5) — standing form of step 7; verify catches deliberately-broken export. Final wave of every module build.
9. **Publish** via changesets.
10. **Migrate remaining projects** one at a time, deleting their duplicated copies.

## 7. Anti-patterns (reject on sight)

- Fat adapter object with all methods (kills tree-shaking) → use standalone capability functions.
- Product/domain concepts inside shared module ("groups", "deals", product names).
- Module owning secrets, or reaching into host domain tables.
- Styled UI shipped from module.
- Capability as separate *published package* when no foreign dependency (use subpath — separate packages create version-matrix maintenance burden).
- Building all modules at once instead of pilot-first.
- Forcing projects onto one framework instead of web-standard handlers.
- Layered Service/Repository/Controller indirection inside adapter when direct provider call suffices (§3 adapter minimalism).
- Module that, on delete test (§1), only relocates complexity into new dependency instead of removing from N apps.
- Shipping module without registering its `apps/consumer` fixture (§5).

## 8. Build execution

Architecture/spec is doc deliverable — get approval, then build. Under `bs-cursor`/`cursor-orchestrator`, **all implementation code written by cursor-agent**, not inline. This skill defines *what* and *how*; orchestrator builds it.

## 9. Boundary mapping (BEFORE §6 candidate confirmation)

Deciding *whether* something is a module, what's core vs capability, which deps it has is **upstream** of §6's per-module extraction audit — and where assumptions do most damage. Same discipline one phase earlier: **read actual impl + call-sites across all projects before stating any boundary.** User often wrote architecture and knows it; priors + filenames + shallow greps usually wrong. Use parallel read-only subagents for breadth; cite `file:line`. See canonical map `platform/docs/specs/2026-06-12-module-registry.md` (living registry) and 5-test for bundling (zero/shared deps · same cadence · no security boundary · no event seam · one concept). Co-usage → preset, never merge.

**The registry is a FILTER, not a backlog.** A row at status `candidate` (survey says ≥3 prior apps) is *hypothesis to be killed or confirmed*, never todo to be implemented. Earns `designed` only by surviving template gates — delete test (§1) above all. Presence ≠ commitment. Mapping a candidate often *removes* it (dissolve) or *shrinks* it (thin seam); that is filter working.

## Learned Rules

### explore-before-mapping-boundaries | fired:1 | 2026-06-12
Recommended boundaries/deps/core-vs-capability from architecture priors + filenames + shallow greps (transactional=own module, webpush=core, fraud=cross-cutting `@platform-modules/risk`, logger absent) → all wrong; user wrote arch, corrected each from memory.
Prevent: before ANY boundary/dep/core-vs-capability claim, dispatch parallel read-only subagents to READ real impl + call-sites; cite `file:line`. Unverified → say "need to check" then check, never assert structure from priors.

### caller-imports-vs-gates | fired:1 | 2026-06-12
Called fraud cross-cutting "`@platform-modules/risk` called by payments" because `payments/finalize.ts` imports it → wrong; only gates EARN referral-commission, DecisionPoint=CLICK|SIGNUP|EARN|WITHDRAW = all referral lifecycle = affiliate-specific.
Prevent: caller importing symbol ≠ coupling. Read what caller DOES with it (what it gates/branches on). One real consumer → keep capability inside that module; extract standalone only when 2nd genuine consumer appears.

### core-needs-universal-not-present | fired:1 | 2026-06-12
Declared webpush a notifications CORE channel from "appears in projects" → wrong, only 4/13, same minority tier as telegram/sms; core deserved nothing but seam.
Prevent: core = UNIVERSAL (all/most consumers) only. Count presence across ALL projects before labeling core; minority feature → opt-in capability subpath, never core. Default (matches §3): core = seam, every channel/feature opt-in.

### read-util-impl-not-filename | fired:1 | 2026-06-12
Assumed logger absent, then assumed wraps pino/consola/winston from name → wrong; one prior app's `packages/core/logger.ts` is CUSTOM zero-dep console-JSON, Workers-safe. Also assumed transactional own module (it's mail's default `send()` verb).
Prevent: before declaring util candidate absent OR naming its deps/shape/runtime-safety, grep ALL projects then READ the file. Never infer lib dep or impl from filename. Sub-feature may be module's default verb / subpath, not a module.

### same-util-varies-backend-across-projects | fired:1 | 2026-06-12
Registry pinned rate-limit backends as memory/neon/do; reading real code: one prior app uses CF-KV+in-memory-fallback, another uses Neon fixed-window. Same util, different storage per project → backend is real swappable axis, not assumed default.
Prevent: when util exists in multiple projects, read EACH impl — storage/runtime differ. Model differing backend as adapter/peer-dep axis (§2 adapter axis), don't hardcode one default as if universal.

### npmrc-scope-routing-only-no-authtoken | fired:1 | 2026-06-14
Committed `.npmrc` auth line `//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}` as "good practice" → wrong; token unset in local dev (workspace-linked deps never hit GHP), so pnpm WARNs on every command (×4/run).
Prevent: committed `.npmrc` holds ONLY scope→registry (`@platform-modules:registry=https://npm.pkg.github.com`). GHP auth injected at publish by CI (`actions/setup-node` registry-url) or local `NODE_AUTH_TOKEN` — never commit authToken line, even env-ref. Verify with fresh `pnpm install` (no WARN).

### local-npm-token-readonly-ci-is-publish-path | fired:1 | 2026-06-16
Ran `pnpm changeset:publish` locally to ship 0.0.1 → E403 "token does not match expected scopes"; local ~/.npmrc GHP token is `read:packages` ONLY (and gh CLI token carries no `packages:write`). Burned cycle root-causing a non-defect as if code were wrong.
Prevent: NEVER publish from local. Only `packages:write` path is CI `release.yml` via `${{ secrets.GITHUB_TOKEN }}`. To ship package, land its source + version on `main`; CI's changesets/action publishes. Local token is INSTALL/read only — use `npm view @platform-modules/<x> --registry=https://npm.pkg.github.com` to VERIFY publish, not to perform one.

### ci-authtoken-must-not-leak-into-turbo-build | fired:1 | 2026-06-16
CI `release.yml` appends `_authToken=${NODE_AUTH_TOKEN}` to ~/.npmrc BEFORE `changeset:publish` (= `turbo build && changeset publish`) → every build subtask WARNs "Failed to replace env in config: ${NODE_AUTH_TOKEN}" (turbo doesn't forward var to build children; builds need no registry auth). Publish itself authed fine — benign, but no-ignored-signals CI defect.
Prevent: scope .npmrc-auth write to publish step only — run `turbo build` BEFORE auth step, or add `NODE_AUTH_TOKEN` to turbo `globalPassThroughEnv`. Sibling of `npmrc-scope-routing-only-no-authtoken` (that rule = committed file; this = CI-runtime file).

### gated-app-needs-typecheck-script-vitest-strips-types | fired:1 | 2026-06-16
Built 4 blueprints in `apps/consumer` with NO `typecheck` script → turbo `gate` silently SKIPPED type-checking them (vitest strips types, so green tests ≠ correct types); 3 real type errors sat unchecked until script wired (#17).
Prevent: every workspace app/pkg the gate covers MUST expose `"typecheck": "tsc --noEmit"` — `turbo run typecheck` only runs on packages that DEFINE the task; missing script is silent hole, not a pass. After adding consumer-harness, confirm app appears in gate's typecheck run.