# Zync → @platform-modules Migration — Design Spec

**Audience: AI coding agents first.** Optimize for activation, not prose. Do not prettify back into paragraphs.

**Status:** DESIGN (brainstorm output). Execution governed by the sibling plan
`docs/plans/2026-06-17-platform-modules-migration.md` (the only NEW plan for this refactor).

**One-line goal:** migrate zync.is to consume `@platform-modules/*` packages instead of its
hand-rolled `@zync/*` capabilities — embed the module seam, migrate data onto module schema,
prove swap-survival parity, delete the replaced host code. Hardening of each adopted module is
a LATER effort, not this refactor.

**Canonical law — read, do not re-inline (one source of truth):**
- Embed procedure: `/home/user/Projects/platform/.claude/skills/platform-embed-module/SKILL.md`
  (+ its `references/module-axes.md` per target module).
- Platform module contracts: `/home/user/Projects/platform/docs/registry.json` (machine query),
  scoped sections of `docs/specs/2026-06-12-module-registry.md` for rationale.
- Host law: this repo's `CLAUDE.md`, `~/.claude/CLAUDE.md`, and host skills
  (`zc-dba`, `zc-orchestrate`, `zc-deploy`, `cursor-orchestrator`) — bind every file written here.
- Spec discipline: host `CLAUDE.md` "Spec is the source of truth" — code+spec never silently disagree.

---

## 1. Scope decisions (USER-DECIDED — do not re-litigate)

- **Schema strategy:** adopt each embeddable module's pgTable schema as the canonical store and
  **migrate zync data onto it** (USER-DECIDED). Execute as **expand → backfill → verify → cutover →
  contract** (forward-only), NEVER a destructive rip — live prod DB (Neon `zync-prod`). End state:
  module schema canonical, legacy `@zync/*` tables dropped only after cutover parity passes.
- **Rollout:** full plan authored now, executed **pilot-first** — prove the embed+parity pattern on
  low-coupling modules in a throwaway worktree before touching woven modules.
- **Keystone treatment:** divergence-tiered (§3). Israeli-statutory keystones stay host-owned;
  generic capabilities zync holds are **upstreamed** to the modules (§5).
- **Specs:** EDIT existing zync capability specs to point at the module seam (§6). Do NOT create new
  per-capability specs. Only NEW docs = this design + the plan.

---

## 2. Two divergence kinds (the spine — treatment differs per kind)

Classify every capability before wiring. Treatment is opposite per kind — misclassifying force-fits.

| Kind | Definition | Treatment |
|---|---|---|
| **Surface-depth** | module + zync share the same contract shape; zync stacks extra (statutory) logic on top | embed module primitive underneath, keep host extension on top, **upstream** generic excess (R5 harvest) |
| **Contract-shape** | module and zync solve the job with genuinely different architectures (not naming) | **do NOT force-fit** — keep host-owned; note module-donor candidacy; defer |

Decision rule per capability: same seam contract (inputs/outputs/error shape, same axis demand) →
surface-depth → embed. Different contract/architecture → contract-shape → defer.

---

## 3. Per-module disposition (the embed roster)

Three tiers. Tier assignment is the verdict; rationale below each.

### Tier 1 — EMBED-DIRECT (adopt schema + migrate data + swap-survival parity)

Seam matches zync; straight embed.

Axes column is canonical from `module-axes.md` (re-derived per reviewer; do NOT intuit). **Axis C = host-injected
store interface — NEVER a duck-typed stub (silent-`[]` data-loss path).** Real Axis-B schema owners (module ships
pgTable → host migration) = `jobs`, `tenancy`, `audit`, `helpdesk` only; `search`/`notifications`/`marketing`
consume **host-owned tables** (no module schema).

| Module | Layer | Replaces in zync | Axes | Axis-B schema? |
|---|---|---|---|---|
| `util` | L0 | crypto/tokens/password/rate-limit/idempotency leaves | — | no |
| `db` (driver) | L0 | `@zync/db` driver init (neon-http/serverless) | D | no |
| `i18n` + `i18n-react` | L0 | i18n surface — **Gate-0b suspect** (Astro-native framework subsystem; prove a real non-framework surface or no-embed) | A, F | no |
| `cache` | L1 | DB-version keys (`financials_version`) — **Gate-0b suspect** (no real cache layer; likely no-embed) | D | no |
| `jobs` | L1 | DO queues + cron + `executionCtx.waitUntil` | A, B, C, D | YES |
| `mail` | L1 | `@zync/notifications` send-email (Resend) | D | no |
| `tenancy` | L1 | `tenant-guards.ts` + admin-rbac + tenant-modules — **Gate-0b: prove `triadSchema` covers zync role/permission/field-permission rbac, else split** (embed isolation triad, keep host field-perm rbac) | A, B, C | YES |
| `ai` | L2 | `@zync/ai` (anthropic/openai/google + RAG) | D | no |
| `audit` | L2 | host audit-log writes | A, B | YES |
| `notifications` | L2 | `@zync/notifications` (telegram/webpush/dedup) — host-owned tables | A, C, D | no |
| `realtime/server` + `realtime-react` | L2 | `@zync/realtime` (WS + DO broadcast) — embed **`/server` subpath ONLY**, not types-only core | A, F | no (DO state) |
| `search` | L2 | zync PG-FTS `search-service` — host-owned tables | A | no |
| `seo` | L2 | host seo (jsonld/sitemap/robots) | — | no |
| `uploads` | L2 | `@zync/storage` (R2 presign/magic-bytes/dims) | A, D | no |
| `helpdesk` | L3 | CRM support center CRUD substrate | A, B | YES |
| `marketing` | L3 | host catalog/marketing — host-owned tables | A, C, D | no |

Axis-C store owners (real impl over zync Postgres, enumerate-faithful): `jobs`(`IdempotencyStore`),
`tenancy`(`TenancyStore`), `notifications`(`PreferenceStore`+`DedupStore`), `marketing`(`ComplianceStore`+`SelfManagedStore`).

Peer note (Axis A): `mail` + `audit` may pull `record-store` (L0) — install transitively if imported.

### Tier 2 — EMBED-SUBSTRATE + KEEP STATUTORY + UPSTREAM

| Module | Embed now | Keep host-side (app-domain) | Upstream (zync→platform, §5) |
|---|---|---|---|
| `auth` | password-hash primitive ONLY (`verify`/`derive`), wired **inside the existing PasswordHashDO offload** | session (jose JWT + `sessionGuard`), oauth, 2fa/TOTP/backup-codes, portal-sessions, invites — module does **NOT** ship these (registry: NOT YET BUILT) → cannot delete host code → not Tier-1 | once module hash engine matches zync, contribute zync's DO-offload + legacy-verify hardening back |
| `tax` | `tax/vat-math` (`applyVat`/`extractVat`/`roundHalfUp`) — currency-agnostic, zero blocker | Form6111, PCN874, CP1255, advance-tax, bad-debt-VAT, withholding, `tax_rates` brackets | (a) generic `scheduleValueAt<T>` so rate type is pluggable → enables embedding rate-resolution; (b) IL VAT dataset 1976–2025 → `tax/rates-table`; (c) generic Chart-of-Accounts (code/type/name) |

**Auth hash-parity hazard (BLOCKER if ignored) — module engine-custom = `PBKDF2-SHA-512 + pepper`; zync = NO pepper,
100k iters, stored format `pbkdf2sha512$<iter>$<salt>$<hash>`, plus a legacy SHA-256 verify path.** Salted hashes
are NOT backfillable (no plaintext) and NOT output-comparable → §4 expand/contract + byte-parity do NOT apply to
credentials. MUST: (1) confirm module engine runs **pepper-less + 100k + zync's exact stored format**, (2) retain
the legacy SHA-256 verify path, (3) **rehash-on-login** as the only migration, (4) run the engine **inside the
PasswordHashDO** — inline hashing re-triggers the CPU-exceeded 500 that forced the DO, and CF WebCrypto hard-caps
PBKDF2 at 100k (210k throws). See project memory `project_auth_cpu_do_offload`.

### Tier 3 — HOST-OWNED, DEFER (contract-shape divergence / WATCH / unbuilt)

Excluded from embed waves. Document as platform-donor candidates; optional later architectural adoption = hardening-later.

| Capability | Why deferred |
|---|---|
| `billing` (money-seam) | contract-shape: module = 3-method charge/refund+settlement→ledger+webhook-dedup; zync = 7-method subscription adapter (checkout/sub-CRUD/portal/invoice-history). Adopting = settlement+dedup+ledger architecture change, not a schema swap. |
| `invoicing` | WATCH (1-donor multideal provider-registry pattern). zync folds invoicing into DB+adapter. Skill Gate-0b: do NOT embed WATCH. zync not a clean donor #2 w/o multi-week refactor. |
| `ledger` | contract-shape: module = append-as-you-go audit GL; zync = derived-at-export GL from documents. Adopting = whole-GL-model switch. Optional later. |
| `entitlements` | platform status DESIGNED/unbuilt — no embed surface. |
| `affiliate` | WATCH + no zync surface. |

---

## 4. Per-module embed contract (every Tier-1/2 module MUST satisfy)

Run inside a **throwaway git worktree** of zync (`git worktree add`), never on a live branch.
Vehicle: **local `file:` link** to `/home/user/Projects/platform/packages/<mod>` (nothing published;
all modules `0.0.0`). Build module first so `dist/` exists.

Per module, ladder — stop at first failing rung, fix, resume:

0. **Gate-0b — confirm zync has a real surface for the module** (skill Step 0b). STOP + reclassify to
   defer if zync's existing code and the module seam are incompatible shapes, or zync has no surface.
   Gate-0b "no" = successful outcome (drop from roster). Known suspects MUST be cleared before wiring:
   - `cache` — zync uses DB-version-keys (`financials_version`), NOT a KV/redis layer → likely no-embed.
   - `i18n` — Astro-native framework subsystem, not hand-rolled code → incompatible-shape risk.
   - `tenancy` rbac — prove module `triadSchema` covers zync's multi-table role/permission/field-permission
     model (`schema/rbac.ts`, `admin-rbac.ts`, `field-permission-rules.ts`, `tenant-guards.ts` dual-layer),
     else split: embed isolation triad, keep host field-permission rbac as extension. Security-critical — do NOT force-fit.
   - `realtime` — core barrel is types-only; embed the **`/server`** subpath only (types-only core = unbuilt for surface).
1. **Wire only the module's axes** (look up in `module-axes.md`). Adapter-minimalism: ONE wiring file,
   no Service/Repo/Controller scaffold. Host keeps owning DB/creds/routes.
2. **Axis B (db) — expand/backfill/verify/cutover/contract**, forward-only. ONLY `jobs`/`tenancy`/`audit`/`helpdesk`
   own module schema; all others use host tables (no migration). Per host `zc-dba`:
   - generate migration from module schema via host drizzle-kit; **slot into the host drizzle journal** (current head
     `0060`) — no out-of-band DDL, journal-replay must stay consistent;
   - backfill module tables from legacy `@zync/*` tables;
   - **parity-verify** row counts + spot field equality before cutover;
   - cutover reads/writes to module path; **account for Hyperdrive** caching in front of prod Neon (`zync-api`)
     at cutover — stale connection/plan can mask schema change;
   - drop legacy tables only after parity passes. NEVER drop a legacy column before cutover parity.
3. **Axis C (stores)** — implement host-injected interface against zync's Postgres. Enumerate-faithfully
   (`list*` must really enumerate; NEVER duck-typed stub — silent-`[]` data-loss path). Inject real `now()`.
4. **Axis D (provider)** — install SDK (real version), register adapter at bootstrap, creds from env
   (NEVER commit). zync providers: Resend (mail), Anthropic/OpenAI/Google (ai), R2 (uploads), Morning (billing — deferred).
5. **Axis E (endpoint)** — mount module `Request→Response` handler on zync's Hono/Workers route; never rewrite handler.
6. **Axis F (react)** — render `-react` provider inside a React island; context does NOT cross island boundaries.
7. **Delete replaced host code.** Can't delete it → module didn't cover the surface → surface that as a WATCH/mismatch, do not leave dead dual paths.

**Verify (swap-survival is the real test) — all MUST hold:**
- `pnpm install` clean + host `tsc --noEmit` green (module ships `.d.ts`).
- host existing test suite stays green.
- **swap-parity assertion (load-bearing):**
  - *deterministic/pure seams* — module output ≡ deleted zync output on real inputs
    (`presign(x)` ≡ old storage output; `applyVat(net,rate)` ≡ old invoice calc).
  - *non-deterministic/stateful seams* (salted hashes, session tokens, stores) — byte-equality is undefined;
    parity = **roundtrip + cross-verify**: an artifact made by old code verifies under new code, AND a new
    artifact verifies under new code (e.g. existing zync password hash verifies via module engine).
- axis-floor: db rows survive migration + module isolation/idempotency holds on zync data;
  provider real round-trip; endpoint returns contract status codes.
- **No new signals** — every install WARN / peer notice / type hint fixed or justified benign.

**Rollback (record per module):** dep line, `.npmrc`, migration (forward-only down), wiring file, deletions.
Drop worktree + revert migration = full undo.

---

## 5. Platform-prerequisites track (bidirectional upstream — zync→platform)

These land in the **platform repo** (separate specs/law), gating Tier-2 embeds. Out of zync's specs;
listed here as upstream dependencies the plan sequences first.

1. **`scheduleValueAt<T>(schedule, date)`** — extract generic date-effective lookup from `tax/resolveVatRate`;
   make rate type pluggable (zync stores NUMERIC decimal, module hardcodes basis-points bigint). BLOCKS embedding rate-resolution.
2. **IL VAT dataset 1976–2025** — harvest zync's authoritative historical rates into `tax/rates-table`.
3. **Generic Chart-of-Accounts** (code/type/name) — harvest from zync CoA; Form6111 overlay stays host-side.

Each upstream obeys platform law (Gate-1 delete-test, adapter-minimalism, §4 seam checklist) — authored via
platform's own `platform-extract-modules` skill, NOT this plan's executors.

---

## 6. Spec-edit mapping (EDIT existing zync specs — do not create new)

Each embedded capability's existing zync spec gets a **boundary edit**, not a rewrite:
- add a header line: "Implementation sourced from `@platform-modules/<mod>` — this spec defines the seam
  zync satisfies + zync-specific extensions; module contract is canonical for the generic surface."
- replace hand-rolled-impl prose with the seam contract reference; keep zync-specific behavior + Israeli-statutory sections.
- add a dated one-line rationale (host CLAUDE.md rule).

Plan step resolves exact spec filenames via grep per capability (do NOT fabricate filenames). Known capability→module mapping:

| Capability (zync spec) | → Module | Tier |
|---|---|---|
| auth cluster (password-hash only; session/oauth/2fa/portal stay host) | `auth` (Tier-2 substrate) | 2 |
| tenant-isolation / rbac (Gate-0b: triad vs host field-perm split) | `tenancy` | 1 |
| storage / uploads / avatar | `uploads` | 1 |
| ai-assistant | `ai` | 1 |
| realtime / activity sync | `realtime` | 1 |
| notifications / email | `mail` + `notifications` | 1 |
| crm-support-center | `helpdesk` | 1 |
| search | `search` | 1 |
| seo | `seo` | 1 |
| i18n | `i18n` | 1 |
| jobs/cron/queues | `jobs` | 1 |
| cache/financials-version | `cache` | 1 |
| audit-log | `audit` | 1 |
| marketing/catalog | `marketing` | 1 |
| tax (vat-math only) | `tax/vat-math` | 2 |
| invoices-core / receipt / recurring / approval / adapters | (none — host-owned) | 3 |
| accountant-export / israeli-tax statutory | (none — host-owned) | 3 |
| billing/payments (Morning) | (none — host-owned) | 3 |

---

## 7. Execution shape (pilot-first; plan expands to waves)

1. **Pilot wave** — prove embed+parity pattern on lowest-coupling, no-Axis-B modules in throwaway worktree:
   **`seo`, `ai`, `uploads`** (+ `tax/vat-math` as pure-import proof). Gate: all §4 verify rungs pass.
2. **L0 foundation** — `util`, `db` driver, `i18n`(+react).
3. **L1 core infra** — `mail`, `jobs` (Axis-B+C; expand/contract), then `tenancy` (riskiest — Axis-B+C +
   security-critical rbac Gate-0b; clear the split decision FIRST). `cache` only if Gate-0b clears.
   `auth` here is the Tier-2 **password-hash substrate only** (no schema migration; rehash-on-login inside PasswordHashDO).
4. **L2 domain** — `audit` (Axis-B), `notifications` (Axis-C stores), `realtime/server`(+react), `search`.
5. **L3 composite** — `helpdesk`, `marketing`.
6. **Platform-prereq track** (parallel, platform repo) — gates any `tax` rate-resolution embed beyond vat-math.
7. **Defer set (Tier 3)** — no embed; spec note + donor-candidacy doc only.

Per host `zc-orchestrate`: each wave gated — verify subagent claims by opening files; live e2e on prod-shaped
DB before declaring a wave done; never trust a completion summary unread.

---

## 8. Architecture Decisions

**Deletion test applied** — no new host abstraction modules introduced by this design; it consumes module
seams and deletes host code. The only new units are wiring files (one per module, adapter-minimalism) which
fail the deletion test on purpose (they ARE the seam, nothing to collapse).

**Accepted dispositions:**
- Tier-3 defer of billing/invoicing/ledger — contract-shape divergence; force-fit rejected (skill Gate-0b).
- Expand/contract over destructive rip for the user's "adopt schema + migrate data" — same end state, safe on live DB.
- Upstream (zync→platform) for tax generics rather than discard (R5 capability-altitude).

**Gate-driven corrections (design-review, 2026-06-17):**
- `auth` retiered 1→2 (substrate hash-primitive only): module ships only the core seam (oauth/2fa/portal-sessions
  NOT BUILT in registry), peppered engine + salted-hash non-comparability break expand/contract → host session/oauth/2fa stay.
- Axis-C added to `jobs`/`tenancy`/`notifications`/`marketing` (data-loss store seam was omitted).
- Axis cells corrected from `module-axes.md`: `tenancy`=A,B,C (not A,D,B); `jobs`=A,B,C,D (not A,D,E);
  real Axis-B schema owners = `jobs`/`tenancy`/`audit`/`helpdesk` only — `search`/`notifications`/`marketing` use host tables.
- Gate-0b suspect list widened: `i18n` (framework-native), `tenancy`-rbac (triad vs field-perm), `realtime` (types-only core).
- Swap-parity split into deterministic byte-equality vs stateful roundtrip+cross-verify.

**Rejected candidates:**
- Embedding `billing`/`invoicing`/`ledger` now — different architecture, not a swap (would be a rewrite, not a migration).
- Using module `resolveVatRate` directly — type/timing mismatch; blocked on `scheduleValueAt<T>` upstream.
- Big-bang rollout — rejected for pilot-first (blast radius on live prod DB).
