# Platform-Modules Migration Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Read the design spec first:** `docs/specs/2026-06-17-platform-modules-migration-design.md` — it is canonical; this plan executes it.

**Goal:** Migrate zync.is to consume `@platform-modules/*` packages in place of hand-rolled `@zync/*` capabilities, embedding only each module's seam, migrating data onto module schema, proving swap-survival parity, and deleting the replaced host code.

**Architecture:** Per-module embed following the design's §4 embed contract (Gate-0b → wire axes → expand/contract DB → swap-parity → delete host code → rollback). All work in a throwaway git worktree, local `file:` vehicle. Three tiers: Tier-1 embed-direct, Tier-2 substrate+keep+upstream (auth hash, tax vat-math), Tier-3 deferred (billing/invoicing/ledger). Pilot-first to prove the pattern before woven modules.

**Tech Stack:** pnpm + turbo monorepo, Hono on Cloudflare Workers (`apps/zync-api`), Astro SSR (`apps/zync-app`/`zync-www`), drizzle + Neon Postgres (head migration `0060`) behind Hyperdrive, vitest, R2/DO/Queues.

---

## Conventions (read once, apply every task)

- **Worktree:** all edits happen in the worktree from Task 0. Never touch `master` directly.
- **Vehicle:** add `"@platform-modules/<mod>": "file:/home/user/Projects/platform/packages/<mod>"` to the consuming package's `package.json`, then `pnpm install`. Build the module first (`pnpm --filter @platform-modules/<mod> build`).
- **Wiring-file isolation (keeps waves parallel):** each module embed creates its OWN wiring file under `apps/zync-api/src/integrations/platform/<mod>.ts` (adapter-minimalism: one file, no Service/Repo/Controller). It exports a `register<Mod>(...)` and/or handler. The shared bootstrap `apps/zync-api/src/index.ts` is edited ONLY by the per-wave **Registration** task — never by a module's wire task. This confines `index.ts` overlap to one serial task per wave.
- **Parity test location:** `apps/zync-api/src/integrations/platform/__tests__/<mod>.parity.test.ts`.
- **Parity test MUST import the real host symbol — never reconstruct it.** A test that hand-reimplements host logic proves only that the module matches the test author's guess, not zync. Import the actual host function/value under test and assert the module agrees. (Seat-probe lesson: codex, given the choice, reconstructed; the contract must forbid it.) Where the swap deletes the host symbol, capture its pre-swap output as a golden fixture in the SAME task and assert the module reproduces the fixture.
- **Execution engine:** codex seat. Every code-writing dispatch (implement + fix) runs `~/.claude/workflows/lib/cx-implement.sh` (codex gpt-5.5); review/integrate/commit run `cx.sh`/`cc.sh` (codex) with an opus/`security-guard` floor on HIGH/trust-boundary diffs. `ca.sh`/cursor/north are NOT used. Driven by `docs/plans/2026-06-17-platform-modules-migration.jsonl` (`meta.seat:"codex"`), slug `platform-modules-migration`.
- **Spec edits:** per design §6, EDIT the existing zync capability spec (boundary header + seam reference + dated rationale). zync commits specs alongside code (host law: spec-is-source-of-truth) — stage the edited spec in the module's commit.
- **DB law:** any Axis-B migration goes through host `zc-dba` — slot into the drizzle journal (head `0060`), forward-only, account for Hyperdrive at cutover. Only `jobs`/`tenancy`/`audit`/`helpdesk` own module schema.
- **Gate per wave (host `zc-orchestrate`):** verify subagent claims by opening files; run host typecheck + suite; do not declare a wave done on an unread summary.
- **Commit:** terse, no co-author (host law). Stage only the module's wiring file, its test, the edited host code, the migration, and the edited spec.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 0 | T0 worktree + vehicle bootstrap | worktree, root `package.json` lockfile | single task |
| 1 (Pilot) | T1 seo, T2 ai, T3 uploads, T4 tax/vat-math | distinct wiring files + distinct host code | ✅ no overlap (register deferred to T5) |
| 1g | T5 pilot registration + GATE | `apps/zync-api/src/index.ts` | single task (serial) |
| 2 (L0) | T6 util, T7 db-driver | distinct host call-sites | ✅ no overlap |
| 2b | T8 i18n (Gate-0b) | `apps/zync-app` i18n | single task (may no-embed) |
| 3 (L1) | T9 mail, T10 jobs | distinct wiring + distinct host code | ✅ no overlap |
| 3g | T11 L1 registration | `index.ts` | single task (serial) |
| 4 (L1 sec) | T12 tenancy (Gate-0b rbac), T13 auth-hash substrate | tenancy queries vs PasswordHashDO | ✅ no overlap |
| 5 (L2) | T14 audit, T15 notifications, T16 realtime/server, T17 search | distinct wiring + host code | ✅ no overlap |
| 5g | T18 L2 registration | `index.ts` | single task (serial) |
| 6 (L3) | T19 helpdesk, T20 marketing | distinct wiring + host code | ✅ no overlap |
| 6g | T21 L3 registration | `index.ts` | single task (serial) |
| 7 | T22 full integration + live e2e gate + merge | whole worktree | single task (serial) |
| P (parallel, platform repo) | T23 scheduleValueAt, T24 IL VAT dataset, T25 generic CoA | platform packages | ✅ separate repo; gates Tier-2 rate-resolution only |

---

## Task 0: Worktree + vehicle bootstrap

**Wave:** 0 · **Blocks:** all · **Blocked by:** —

**Files:**
- Create: git worktree `.worktrees/platform-migration`
- Modify: (in worktree) per-package `package.json` as each module is added

- [ ] **Step 1: Create isolated worktree**

Run:
```bash
git -C /home/user/Projects/zync.is worktree add .worktrees/platform-migration -b platform-modules-migration
```
Expected: `Preparing worktree (new branch 'platform-modules-migration')`.

- [ ] **Step 2: Confirm platform module builds work (sanity on one module)**

Run:
```bash
pnpm --filter @platform-modules/seo build && ls /home/user/Projects/platform/packages/seo/dist
```
Expected: `dist/` contains `index.js` + `index.d.ts`. If empty, fix the module build before proceeding.

- [ ] **Step 3: Create the wiring-file directory + barrel**

Create `apps/zync-api/src/integrations/platform/index.ts` (in worktree):
```ts
// Platform-module wiring barrel. Each module adds one file here; index.ts imports from this barrel.
// Adapter-minimalism: wiring only — no Service/Repo/Controller scaffold.
export {}
```

- [ ] **Step 4: Commit scaffold**

```bash
git add apps/zync-api/src/integrations/platform/index.ts
git commit -m "chore(migration): worktree + platform wiring scaffold"
```

---

## Task 1: Embed `seo` (pilot — pure, no DB)

> **VERIFIED 2026-06-30:** a repo grep found NO hand-rolled JSON-LD/sitemap host code in zync. Per Step 1
> (Gate-0b), `seo` is therefore expected to resolve to **no-embed** (nothing to swap; not a parity case).
> Run Step 1 to confirm; if confirmed, mark no-embed and skip. The pilot's PATTERN-EXEMPLAR role moves to a
> module that has real host code to swap — **`uploads` (T3, pure-function parity, already validated end-to-end
> in the seat probe)** is the canonical exemplar; `ai` (T2) and `tax/vat-math` (T4) also have real host code.

**Wave:** 1 · **Blocks:** T5 · **Blocked by:** T0
**Axes:** none (pure functions). **Tier:** 1.

**Files:**
- Create: `apps/zync-api/src/integrations/platform/seo.ts`
- Create: `apps/zync-api/src/integrations/platform/__tests__/seo.parity.test.ts`
- Modify: host SEO call-sites (resolved in Step 4 via grep)
- Modify (spec): existing zync seo spec (resolve via `grep -rl seo docs/specs`)

- [ ] **Step 1: Gate-0b — confirm a real non-framework SEO surface exists**

Run:
```bash
grep -rln "jsonld\|json-ld\|sitemap\|structured.data\|robots" apps/ packages/ | grep -v node_modules
```
Expected: host code that builds JSON-LD / sitemap by hand. If only Astro-native config exists → STOP, mark `seo` no-embed (Gate-0b success), skip to T2.

- [ ] **Step 2: Add vehicle + build**

```bash
pnpm --filter @platform-modules/seo build
# add to apps/zync-app (or wherever SEO renders) package.json dependencies:
#   "@platform-modules/seo": "file:/home/user/Projects/platform/packages/seo"
pnpm install
```
Expected: install clean, zero WARN. Any WARN → fix or justify before continuing.

- [ ] **Step 3: Write the swap-parity test (FAILING) — module output ≡ host output**

Create `apps/zync-api/src/integrations/platform/__tests__/seo.parity.test.ts`:
```ts
import { describe, it, expect } from 'vitest'
import { serializeJsonLd } from '@platform-modules/seo/jsonld'
// import the EXISTING host serializer (resolve exact path in Step 1 grep):
import { buildJsonLd as hostBuildJsonLd } from '../../../lib/seo' // ← replace with real path

describe('seo swap-parity', () => {
  it('module JSON-LD equals host JSON-LD on real input', () => {
    const input = { '@type': 'Organization', name: 'Zync', url: 'https://zync.is' }
    expect(serializeJsonLd(input)).toBe(hostBuildJsonLd(input))
  })
})
```

- [ ] **Step 4: Run test, verify it FAILS**

Run: `pnpm --filter @zync/... vitest run integrations/platform/__tests__/seo.parity.test.ts`
Expected: FAIL (import path unresolved or output mismatch). Use the failure to pin the exact host serializer path.

- [ ] **Step 5: Write the wiring file**

Create `apps/zync-api/src/integrations/platform/seo.ts`:
```ts
// SEO seam: re-export module functions so host call-sites import from one place.
export { serializeJsonLd } from '@platform-modules/seo/jsonld'
export { buildSitemap } from '@platform-modules/seo/sitemap'
export { buildRobots } from '@platform-modules/seo/robots'
```

- [ ] **Step 6: Repoint host call-sites + DELETE host implementation**

Replace host `buildJsonLd`/sitemap/robots impl with imports from the wiring file. Delete the now-dead host functions. If a call-site cannot be repointed → the module misses that surface; record it as a WATCH in the commit message, keep host code for that case only.

- [ ] **Step 7: Run parity test + host suite, verify PASS + green**

Run:
```bash
pnpm --filter @zync/... vitest run integrations/platform/__tests__/seo.parity.test.ts
pnpm turbo typecheck && pnpm turbo test
```
Expected: parity PASS; typecheck + suite green; no new WARN.

- [ ] **Step 8: Edit the existing seo spec (boundary header)**

In the resolved seo spec, add at top: `> Implementation sourced from @platform-modules/seo — this spec defines the seam zync satisfies; module contract is canonical for jsonld/sitemap/robots. (2026-06-17: migrated to platform module.)` Replace hand-rolled-impl prose with the seam reference; keep zync-specific SEO behavior.

- [ ] **Step 9: Commit + record rollback**

```bash
git add apps/zync-api/src/integrations/platform/seo.ts \
        apps/zync-api/src/integrations/platform/__tests__/seo.parity.test.ts \
        <repointed-host-files> <edited-seo-spec> <package.json>
git commit -m "feat(migration): embed @platform-modules/seo — swap-parity PASS, host seo deleted"
```
Rollback: revert this commit + drop the `file:` dep line. No migration to undo.

---

## Task 2: Embed `ai` (pilot — Axis D)

**Wave:** 1 · **Blocks:** T5 · **Blocked by:** T0
**Axes:** D (provider: Anthropic/OpenAI/Google). **Tier:** 1.

**Files:**
- Create: `apps/zync-api/src/integrations/platform/ai.ts`
- Create: `apps/zync-api/src/integrations/platform/__tests__/ai.parity.test.ts`
- Modify: `packages/ai` consumers / `apps/zync-api/src/ai/*` call-sites
- Modify (spec): zync ai-assistant spec (resolve via `grep -rl ai-assistant docs/specs`)

- [ ] **Step 1: Gate-0b** — confirm `@zync/ai` adapter shape (anthropic/openai/google + RAG) maps to module `ai` adapters. Run `grep -rln "anthropic\|openai\|generateText\|chat" packages/ai/src`. If RAG embed/retrieve has no module counterpart, keep RAG host-side, embed only the chat-completion adapter.

- [ ] **Step 2: Vehicle + build** — `pnpm --filter @platform-modules/ai build`; add `file:` dep to `packages/ai` (or `apps/zync-api`); `pnpm install` (clean).

- [ ] **Step 3: Write FAILING swap-parity test** — assert module adapter and host adapter produce the same request shape for a fixed prompt (mock the SDK; compare the outgoing provider request body, not the live completion):
```ts
import { describe, it, expect, vi } from 'vitest'
import { makeAnthropicAdapter } from '@platform-modules/ai/anthropic'
import { hostAnthropicCall } from '../../../ai/adapters/anthropic' // ← real path
it('module + host build identical Anthropic request body', async () => {
  const captured: any[] = []
  const fetchSpy = vi.fn(async (_u, init) => { captured.push(JSON.parse(String(init?.body))); return new Response('{}') })
  // drive both with same model+messages, assert captured[0] deep-equals captured[1]
  expect(captured[0]).toEqual(captured[1])
})
```

- [ ] **Step 4: Run, verify FAIL.** `pnpm --filter @zync/ai vitest run ...ai.parity.test.ts` → FAIL.

- [ ] **Step 5: Write wiring file** `apps/zync-api/src/integrations/platform/ai.ts`: register the three provider adapters (`makeAnthropicAdapter`/`makeOpenAIAdapter`/`makeGoogleAdapter`) reading creds from `env` (never commit). Export a `getAiAdapter(env, provider)` factory.

- [ ] **Step 6: Repoint host chat call-sites to the factory; DELETE host adapters.** Keep RAG host-side if Step-1 found no module counterpart.

- [ ] **Step 7: Run parity + suite + typecheck — PASS + green; provider real round-trip smoke** (one live call per provider with test creds).

- [ ] **Step 8: Edit ai-assistant spec** boundary header (per §6).

- [ ] **Step 9: Commit + rollback note** (revert commit + drop dep; no migration).

---

## Task 3: Embed `uploads` (pilot — Axis A,D)

**Wave:** 1 · **Blocks:** T5 · **Blocked by:** T0
**Axes:** A (peer `util`), D (R2 presign-s3compat / r2-binding). **Tier:** 1.

**Files:**
- Create: `apps/zync-api/src/integrations/platform/uploads.ts` + parity test
- Modify: `packages/storage/*` call-sites (presign, magic-bytes, image-dims, avatar proxy)
- Modify (spec): settings-module / storage spec

- [ ] **Step 1: Gate-0b** — confirm `@zync/storage` surface (R2 presign + magic-byte guard + dims) maps to module `uploads` subpaths (`magic-bytes`, `image-dimensions`, `presign-s3compat`, `r2-binding`). Install peer `util` (Axis A).
- [ ] **Step 2: Vehicle + build** both `uploads` and peer `util`.
- [ ] **Step 3: FAILING swap-parity test** — assert `magicBytes(buf)` ≡ host magic-byte guard verdict on a real PNG/JPEG/PDF fixture, and `presignPut(key)` produces an equivalent signed URL structure (host derived S3 creds from CF token — see memory `project_r2_presign_creds_gap`). Use the recorded zync presign inputs.
- [ ] **Step 4: Run, verify FAIL.**
- [ ] **Step 5: Wiring file** — register R2 binding adapter + presign with env creds; export `presignPut`/`presignGet`/`guardMagicBytes`/`imageDimensions`.
- [ ] **Step 6: Repoint host storage call-sites (avatar upload authed proxy, attachment presign); DELETE host impl.**
- [ ] **Step 7: parity + suite + typecheck PASS; live R2 presign PUT 200 smoke** (memory: only live test catches prod-secret presign 400).
- [ ] **Step 8: Edit storage/settings spec** boundary header.
- [ ] **Step 9: Commit + rollback** (no migration).

---

## Task 4: Embed `tax/vat-math` (pilot — Tier-2 pure import)

**Wave:** 1 · **Blocks:** T5 · **Blocked by:** T0
**Axes:** none (pure math). **Tier:** 2 (substrate; statutory stays host).

**Files:**
- Create: `apps/zync-api/src/integrations/platform/tax.ts` + parity test
- Modify: invoice/receipt VAT calc call-sites
- Modify (spec): invoices-core / israeli-tax spec (add seam note for vat-math only)

- [ ] **Step 1:** Vehicle + build `tax`. Add `file:` dep.
- [ ] **Step 2: FAILING swap-parity test** — assert `applyVat(netAgorot, rate)` and `extractVat(grossAgorot, rate)` ≡ zync's existing inline invoice VAT calc on real invoice rows (18% IL). Use agorot bigint to match module precision; convert zync NUMERIC decimal at the boundary:
```ts
import { applyVat, extractVat } from '@platform-modules/tax/vat-math'
it('applyVat equals host invoice VAT on real line', () => {
  const net = 10000n // ₪100.00 in agorot
  const rate = 1800n // 18% in basis points
  const { vat, gross } = applyVat(net, rate)
  expect(vat).toBe(1800n); expect(gross).toBe(11800n)
  // compare against host calc on the same line (decimal→agorot at boundary)
})
```
- [ ] **Step 3: Run, verify FAIL.**
- [ ] **Step 4: Wiring file** `tax.ts` — re-export `applyVat`/`extractVat`/`roundHalfUp`; add decimal↔agorot/basis-point converters at the host boundary.
- [ ] **Step 5: Repoint invoice/receipt VAT math to module; DELETE host inline VAT arithmetic.** KEEP all statutory code (Form6111/PCN874/CP1255/advance-tax/bad-debt) untouched. Do NOT touch `resolveVatRate` — blocked on T23.
- [ ] **Step 6: parity + suite + typecheck PASS.** Add a tax-total invariant assertion across a sample of real invoices (gross = net + vat).
- [ ] **Step 7: Edit invoices-core spec** — note VAT math sourced from `@platform-modules/tax/vat-math`; statutory stays host.
- [ ] **Step 8: Commit + rollback** (no migration).

---

## Task 5: Pilot registration + GATE

**Wave:** 1g · **Blocks:** T6+ · **Blocked by:** T1,T2,T3,T4

**Files:** Modify `apps/zync-api/src/index.ts`, `apps/zync-api/src/integrations/platform/index.ts`

- [ ] **Step 1:** Wire the pilot adapters into bootstrap — import `registerAi`/`registerUploads` from the barrel; mount in `index.ts` startup. `seo`/`tax` are pure re-exports (no registration).
- [ ] **Step 2:** Run full `pnpm turbo typecheck && pnpm turbo test` — green, no new WARN.
- [ ] **Step 3: PILOT GATE (host `zc-orchestrate` + advisor-substitute):** dispatch the Opus code-reviewer subagent over the 4 pilot embeds — confirm: adapter-minimalism (one wiring file each, no scaffold), host code actually deleted (not dual-pathed), parity assertions load-bearing, no Axis mis-wire. Fix all findings before proceeding. **This gate validates the pattern for all later waves.**
- [ ] **Step 4: Commit.** `feat(migration): pilot wave registered — seo/ai/uploads/tax-vat-math, gate PASS`

---

## Task 6: Embed `util` (L0)

**Wave:** 2 · **Blocks:** — · **Blocked by:** T5
**Axes:** none (leaves). **Tier:** 1.

**Files:** Create `integrations/platform/util.ts` + parity test; Modify host crypto/token/rate-limit/idempotency call-sites; Modify util/security spec.

- [ ] **Step 1: Gate-0b** — map zync `packages/utils` + crypto leaves to module `util` subpaths (`crypto`, `tokens`, `password`, `rate-limit`, `idempotency`, `ssrf-guard`, …). List each leaf zync uses.
- [ ] **Step 2:** Vehicle + build `util`.
- [ ] **Step 3: FAILING swap-parity test** — for each adopted leaf assert module ≡ host on real inputs (e.g. `hmac(key,msg)` byte-equal; `rateLimit` verdict equal; `idempotencyKey(parts)` equal). For salted/random leaves use roundtrip+cross-verify (design §4), not byte-equality.
- [ ] **Step 4: Run, verify FAIL.**
- [ ] **Step 5: Wiring file** re-exporting adopted leaves.
- [ ] **Step 6: Repoint host call-sites; DELETE host leaves** that the module covers.
- [ ] **Step 7: parity + suite + typecheck PASS.**
- [ ] **Step 8: Edit util/security spec.**
- [ ] **Step 9: Commit + rollback** (no migration).

---

## Task 7: Embed `db` driver (L0)

**Wave:** 2 · **Blocks:** — · **Blocked by:** T5
**Axes:** D (driver: neon-http / neon-serverless). **Tier:** 1. **No schema change** — driver init only.

**Files:** Create `integrations/platform/db.ts` + test; Modify `packages/db/src/client.ts` (or wherever drizzle client is constructed) + Hyperdrive wiring; Modify db spec.

- [ ] **Step 1: Gate-0b** — confirm zync constructs its drizzle client via a swappable driver init (Neon + Hyperdrive). Read `packages/db/src/*client*`.
- [ ] **Step 2:** Vehicle + build `db`.
- [ ] **Step 3: FAILING swap-parity test** — module-driver-constructed client and host client return identical results for a fixed read query against a test DB; assert row-equality.
- [ ] **Step 4: Run, verify FAIL.**
- [ ] **Step 5: Wiring file** — construct drizzle client via `@platform-modules/db/neon-http` (and `neon-serverless` where used), preserving Hyperdrive connection string handling (memory `reference_prod_db_hyperdrive`).
- [ ] **Step 6: Repoint client construction; DELETE host driver init.** Schema + queries unchanged.
- [ ] **Step 7: typecheck + full suite green; one live read against prod-shaped DB.**
- [ ] **Step 8: Edit db spec** driver-source note.
- [ ] **Step 9: Commit + rollback** (no migration).

---

## Task 8: `i18n` Gate-0b (L0) — may no-embed

**Wave:** 2b · **Blocks:** — · **Blocked by:** T5
**Axes:** A, F. **Tier:** 1 (Gate-0b suspect).

- [ ] **Step 1: Gate-0b decision** — Run `grep -rln "i18n\|locale\|t(\|useTranslation" apps/ packages/ | grep -v node_modules`. If zync's i18n is Astro-native framework routing (not hand-rolled message formatting), this is an incompatible-shape no-embed → record Gate-0b success, SKIP the rest, note in plan status. Only proceed if a real non-framework message-formatting/RTL/plural surface exists.
- [ ] **Step 2 (only if surface exists):** embed `i18n` (`format`/`plural`/`rtl`) + `i18n-react` island following the pilot pattern (vehicle → FAILING parity test on `formatNumber`/`pluralize`/RTL ≡ host → wire → delete host → PASS → spec edit → commit). React provider goes inside an island (context does not cross island boundaries).

---

## Task 9: Embed `mail` (L1)

**Wave:** 3 · **Blocks:** T11 · **Blocked by:** T5
**Axes:** D (Resend). **Tier:** 1. **No module schema** (host owns templates table).

**Files:** Create `integrations/platform/mail.ts` + test; Modify `packages/notifications/src/send-email.ts` consumers; Modify notifications/email spec.

- [ ] **Step 1: Gate-0b** — confirm zync transactional send is Resend (memory: L1 system mail). Map to module `mail/resend`.
- [ ] **Step 2:** Vehicle + build `mail` (+ peer `record-store` if imported, Axis A).
- [ ] **Step 3: FAILING swap-parity test** — module Resend adapter and host `send-email` build identical Resend API request body (mock fetch, compare body) for a fixed template render.
- [ ] **Step 4: Run, verify FAIL.**
- [ ] **Step 5: Wiring file** — register `mail/resend` adapter with env creds; export `sendMail(env, msg)`.
- [ ] **Step 6: Repoint host send-email to module; DELETE host Resend send.** Keep host template rendering (host-owned).
- [ ] **Step 7: parity + suite + typecheck PASS; one live test send.**
- [ ] **Step 8: Edit email spec.**
- [ ] **Step 9: Commit + rollback** (no migration).

---

## Task 10: Embed `jobs` (L1 — Axis A,B,C,D)

**Wave:** 3 · **Blocks:** T11 · **Blocked by:** T5
**Axes:** A (peer `db`), B (own schema), C (`IdempotencyStore`), D (cf-queues / do-runner). **Tier:** 1. **Riskiest in this wave (Axis-B + C).**

**Files:** Create `integrations/platform/jobs.ts` + test; Create migration via zc-dba; Modify `apps/zync-api/src/queue*`, `cron/*`, `durable-objects/*`; Modify jobs/cron spec.

- [ ] **Step 1: Gate-0b** — map zync job surfaces (DO queues, cron, `executionCtx.waitUntil` fire-and-forget — memory: waitUntil bug) to module `jobs` adapters (`cf-queues`, `do-runner`, `db-poll`). Confirm the module's `IdempotencyStore` interface (Axis C).
- [ ] **Step 2:** Vehicle + build `jobs` + peer `db`.
- [ ] **Step 3: Axis-C store impl (FIRST — data-loss seam)** — implement `IdempotencyStore` over zync Postgres, enumerate-faithful (never duck-typed stub). Inject real `now()`. Write a unit test proving `list`/`claim`/`markProcessed` actually persist + enumerate.
- [ ] **Step 4: Axis-B migration (zc-dba, expand)** — generate module `jobs` schema migration, slot into drizzle journal after `0060`. Backfill from existing `invoice-generation-jobs`/`uniform-export-jobs`/`tenant-export-jobs`. Parity-verify row counts before cutover.
- [ ] **Step 5: FAILING swap-parity test** — enqueue→consume a fixed job via module path; assert same effect + idempotency (double-deliver = single effect) as host path on zync data.
- [ ] **Step 6: Run, verify FAIL.**
- [ ] **Step 7: Wiring file** — register cf-queues + do-runner adapters + inject the Axis-C store.
- [ ] **Step 8: Cutover job enqueue/consume to module; account for Hyperdrive; DELETE host queue/cron plumbing the module replaces.** Keep job business-logic handlers (host-owned).
- [ ] **Step 9: parity + suite + typecheck PASS; live enqueue→consume on prod-shaped DB; idempotency holds.**
- [ ] **Step 10: Drop legacy job tables only after parity passes.**
- [ ] **Step 11: Edit jobs/cron spec.**
- [ ] **Step 12: Commit + rollback** (revert commit + forward-only down-migration).

---

## Task 11: L1 registration

**Wave:** 3g · **Blocks:** T12+ · **Blocked by:** T9,T10
**Files:** Modify `apps/zync-api/src/index.ts` + barrel.

- [ ] **Step 1:** Wire `registerMail`/`registerJobs` (incl. queue + DO bindings) into bootstrap.
- [ ] **Step 2:** Full typecheck + suite green; no new WARN.
- [ ] **Step 3:** Commit `feat(migration): L1 mail+jobs registered`.

---

## Task 12: Embed `tenancy` (L1 sec — Axis A,B,C) — security-critical

**Wave:** 4 · **Blocks:** — · **Blocked by:** T11
**Axes:** A (peers `db`,`auth`), B (own schema), C (`TenancyStore`). **Tier:** 1 (Gate-0b rbac split).

**Files:** Create `integrations/platform/tenancy.ts` + test; Create migration via zc-dba; Modify `packages/db/src/queries/tenant-guards.ts` + ~40 query call-sites; Modify tenant-isolation spec.

- [ ] **Step 1: Gate-0b rbac classification (BLOCKING)** — read `packages/db/src/schema/rbac.ts`, `admin-rbac.ts`, `field-permission-rules.ts`, `tenant-guards.ts`. Decide: does module `triadSchema` cover zync's role/permission/field-permission model? **If not → SPLIT:** embed only the tenant-isolation triad (tenant/membership/role), KEEP host field-permission rbac as a host extension. Record the decision in the commit. Do NOT force-fit the security layer.
- [ ] **Step 2:** Vehicle + build `tenancy` + peers `db`,`auth`.
- [ ] **Step 3: Axis-C `TenancyStore` impl** over zync Postgres, enumerate-faithful; unit-test persistence + enumeration.
- [ ] **Step 4: Axis-B migration (zc-dba, expand)** — module tenancy schema, slot after head migration; backfill from `tenants`/`tenant-modules`/`admin-rbac`; parity-verify counts.
- [ ] **Step 5: FAILING swap-parity test (load-bearing security)** — for a matrix of (tenant, actor, resource): module isolation check ≡ host `assertTenantOwnsOrThrow` verdict, INCLUDING cross-tenant DENY (forged-tenant must 403). Reuse the existing cross-tenant e2e fixtures (memory: isolation FK + IDOR waves).
- [ ] **Step 6: Run, verify FAIL.**
- [ ] **Step 7: Wiring file** — inject `TenancyStore`; export the isolation guard the module provides.
- [ ] **Step 8: Repoint `tenant-guards.ts` + call-sites to module guard; DELETE host guard impl the module replaces.** Keep field-permission rbac if split in Step 1. Cross-tenant predicate must remain on every by-id path (memory: 62 IDOR holes).
- [ ] **Step 9: parity + suite + typecheck PASS; live cross-tenant e2e (forged JWT 403, revoke-blocks).**
- [ ] **Step 10: Drop legacy rbac tables only after parity.**
- [ ] **Step 11: Edit tenant-isolation spec.**
- [ ] **Step 12: Commit + rollback** (revert + down-migration). **Then dispatch security-guard agent** over the isolation diff (fresh adversarial pass — host law on security-critical change).

---

## Task 13: Embed `auth` hash substrate (L1 sec — Tier-2)

**Wave:** 4 · **Blocks:** — · **Blocked by:** T11
**Axes:** D (hash engine). **Tier:** 2 substrate. **No schema migration** (rehash-on-login). KEEP session/oauth/2fa/portal-sessions host-side.

**Files:** Create `integrations/platform/auth-hash.ts` + test; Modify `packages/auth/src/password.ts` + PasswordHashDO; Modify auth spec.

- [ ] **Step 1: Hash-parity confirmation (BLOCKING — design §3 hazard)** — read `packages/auth/src/password.ts`. Confirm: zync format `pbkdf2sha512$<iter>$<salt>$<hash>`, 100k iters, NO pepper, legacy SHA-256 verify path. Confirm module `auth/engine-custom` can run **pepper-less + 100k + zync's exact stored format**. If the module engine forces a pepper or different format → it cannot verify existing hashes → either configure it off or KEEP host hashing (Gate-0b no-embed for the hash leaf).
- [ ] **Step 2:** Vehicle + build `auth`.
- [ ] **Step 3: FAILING roundtrip+cross-verify test (NOT byte-equality)** — an existing zync hash (real stored format) MUST verify via the module engine; a new module-derived hash MUST verify via the module engine; legacy SHA-256 hash MUST still verify:
```ts
it('module engine verifies existing zync pbkdf2sha512 hash', async () => {
  const stored = 'pbkdf2sha512$100000$<salt>$<hash>' // real zync sample
  expect(await moduleVerify('correct horse', stored)).toBe(true)
  expect(await moduleVerify('wrong', stored)).toBe(false)
})
```
- [ ] **Step 4: Run, verify FAIL.**
- [ ] **Step 5: Wiring file** — wrap module `verify`/`derive` so it runs **inside PasswordHashDO** (memory: inline hashing re-triggers CPU-exceeded 500; CF WebCrypto caps PBKDF2 at 100k). Preserve legacy SHA-256 verify branch. Implement rehash-on-login: on successful legacy/old-format verify, re-derive with module engine and update the row.
- [ ] **Step 6: Repoint password derive/verify to the DO-wrapped module engine; DELETE host PBKDF2 derive only (keep DO offload harness + legacy verify).**
- [ ] **Step 7: parity + suite + typecheck PASS; live signup + login + change-password (0×500); login with a pre-existing account rehashes.**
- [ ] **Step 8: Edit auth spec** — hash sourced from module; session/oauth/2fa stay host.
- [ ] **Step 9: Commit + rollback** (revert; no migration — rehash is forward-safe).

---

## Task 14: Embed `audit` (L2 — Axis A,B)

**Wave:** 5 · **Blocks:** T18 · **Blocked by:** T11
**Axes:** A (peer `db`), B (own schema). **Tier:** 1.

**Files:** Create `integrations/platform/audit.ts` + test; Create migration; Modify host audit-log write call-sites; Modify audit spec.

- [ ] **Step 1: Gate-0b** — map zync audit-log writes (host law: audit-log requirement) to module `audit` (`diff`/`redact`/`retention`).
- [ ] **Step 2:** Vehicle + build `audit` + peer `db`.
- [ ] **Step 3: Axis-B migration (expand)** — module audit schema, slot after head; backfill existing audit rows; parity-verify counts.
- [ ] **Step 4: FAILING swap-parity test** — module `diff(before,after)` + `redact` ≡ host audit entry on real change events.
- [ ] **Step 5: Run, verify FAIL.**
- [ ] **Step 6: Wiring file** — export `recordAudit(...)` backed by module.
- [ ] **Step 7: Repoint audit writes; DELETE host audit impl.**
- [ ] **Step 8: parity + suite + typecheck PASS; live audit write + retention check.**
- [ ] **Step 9: Drop legacy audit table after parity.**
- [ ] **Step 10: Edit audit spec; Commit + rollback** (revert + down-migration).

---

## Task 15: Embed `notifications` (L2 — Axis A,C,D, host tables)

**Wave:** 5 · **Blocks:** T18 · **Blocked by:** T11
**Axes:** A (peer `util`), C (`PreferenceStore`+`DedupStore`), D (telegram/webpush). **Tier:** 1. **No module schema** (host tables).

**Files:** Create `integrations/platform/notifications.ts` + test; Modify `packages/notifications` (telegram/webpush/dedup) call-sites; Modify notifications spec.

- [ ] **Step 1: Gate-0b** — map zync telegram + webpush(VAPID) + dedup to module `notifications` channels. Confirm `PreferenceStore` + `DedupStore` interfaces (Axis C).
- [ ] **Step 2:** Vehicle + build `notifications` + peer `util`.
- [ ] **Step 3: Axis-C store impls (FIRST)** — `PreferenceStore` + `DedupStore` over zync Postgres, enumerate-faithful (`listSuppressed` must really enumerate — silent-`[]` data-loss path). Unit-test persistence + enumeration.
- [ ] **Step 4: FAILING swap-parity test** — module webpush/telegram adapters build identical provider request bodies as host; dedup yields single-winner on double-send.
- [ ] **Step 5: Run, verify FAIL.**
- [ ] **Step 6: Wiring file** — register channels + inject Axis-C stores.
- [ ] **Step 7: Repoint host notify call-sites; DELETE host telegram/webpush/dedup impl.**
- [ ] **Step 8: parity + suite + typecheck PASS; live telegram + webpush send.**
- [ ] **Step 9: Edit notifications spec; Commit + rollback** (no migration).

---

## Task 16: Embed `realtime/server` (L2 — Axis A,F)

**Wave:** 5 · **Blocks:** T18 · **Blocked by:** T11
**Axes:** A, F. **Tier:** 1. **Embed `/server` subpath ONLY** (types-only core = no-embed). DO state, no pg migration.

**Files:** Create `integrations/platform/realtime.ts` + test; Modify `apps/zync-api/src/realtime/*` + `durable-objects/*` + `packages/realtime`; Modify realtime spec; (Axis F) `realtime-react` island in `apps/zync-app`.

- [ ] **Step 1: Gate-0b** — confirm zync has a real WS+DO realtime surface (it does: `apps/zync-api/src/realtime`, `durable-objects`). Target module `realtime/server` (broadcast/hibernatable-accept/publish/queue-consumer/timing-safe internal-secret).
- [ ] **Step 2:** Vehicle + build `realtime` (use `/server`) + `realtime-react`.
- [ ] **Step 3: FAILING swap-parity test** — module `broadcast`/`publish` deliver the same message envelope to subscribers as host on a fixed event; timing-safe internal-secret check matches.
- [ ] **Step 4: Run, verify FAIL.**
- [ ] **Step 5: Wiring file** — wire module `/server` DO helpers; preserve session-auth on connect (memory: WS auth via sessions).
- [ ] **Step 6: Repoint DO broadcast/publish to module; DELETE host WS broadcast impl.** Render `realtime-react` provider inside a React island (context does not cross island boundaries).
- [ ] **Step 7: parity + suite + typecheck PASS; live WS connect + broadcast + auth-deny smoke.**
- [ ] **Step 8: Edit realtime spec; Commit + rollback** (no migration).

---

## Task 17: Embed `search` (L2 — Axis A, host tables)

**Wave:** 5 · **Blocks:** T18 · **Blocked by:** T11
**Axes:** A (peers `db`,`util`). **Tier:** 1. **No module schema** (host tables; PG FTS wrapper).

**Files:** Create `integrations/platform/search.ts` + test; Modify `apps/zync-api/src/search/*`; Modify search spec.

- [ ] **Step 1: Gate-0b** — confirm zync PG-FTS `search-service` maps to module `search` (rank/sanitize wrapper over host tables, tenant-filtered).
- [ ] **Step 2:** Vehicle + build `search` + peers.
- [ ] **Step 3: FAILING swap-parity test** — module search `rank`/`sanitize` + query ≡ host result ordering on a fixed corpus + query; tenant filter preserved (no cross-tenant leak).
- [ ] **Step 4: Run, verify FAIL.**
- [ ] **Step 5: Wiring file** — wrap module search over host tables; preserve tenant predicate.
- [ ] **Step 6: Repoint host search-service to module; DELETE host rank/sanitize impl.**
- [ ] **Step 7: parity + suite + typecheck PASS; live search incl. tenant-isolation check.**
- [ ] **Step 8: Edit search spec; Commit + rollback** (no migration).

---

## Task 18: L2 registration

**Wave:** 5g · **Blocks:** T19+ · **Blocked by:** T14,T15,T16,T17
**Files:** Modify `index.ts` + barrel.

- [ ] **Step 1:** Wire `registerAudit`/`registerNotifications`/`registerRealtime`/`registerSearch` into bootstrap (incl. DO + queue bindings for realtime).
- [ ] **Step 2:** Full typecheck + suite green; no new WARN.
- [ ] **Step 3:** Commit `feat(migration): L2 registered`.

---

## Task 19: Embed `helpdesk` (L3 — Axis A,B)

**Wave:** 6 · **Blocks:** T21 · **Blocked by:** T18
**Axes:** A (peer `db`), B (own schema). **Tier:** 1.

**Files:** Create `integrations/platform/helpdesk.ts` + test; Create migration; Modify `apps/zync-api/src/routes/support/*`; Modify crm-support-center spec.

- [ ] **Step 1: Gate-0b** — map zync CRM support center (inbound multi-channel, SLA, kanban — memory) to module `helpdesk` CRUD substrate. zync is RICHER (SLA/kanban/realtime) → embed the CRUD substrate, KEEP zync's SLA/kanban/source-aware-reply as host extension (surface-depth divergence). Note pipeline_stages relational decision (memory `decision_pipeline_stages_relational`).
- [ ] **Step 2:** Vehicle + build `helpdesk` + peer `db`.
- [ ] **Step 3: Axis-B migration (expand)** — module helpdesk schema, slot after head; backfill from `support`/`ticket-sla`; parity-verify counts. KEEP host SLA/kanban tables.
- [ ] **Step 4: FAILING swap-parity test** — module ticket CRUD ≡ host ticket CRUD on real tickets; host SLA/kanban still functions on module-backed tickets.
- [ ] **Step 5: Run, verify FAIL.**
- [ ] **Step 6: Wiring file** — module CRUD substrate; host SLA/kanban/reply layered on top.
- [ ] **Step 7: Repoint ticket CRUD to module; DELETE host CRUD impl.** Keep SLA/kanban/source-aware-reply host-side.
- [ ] **Step 8: parity + suite + typecheck PASS; live ticket create/reply/SLA e2e.**
- [ ] **Step 9: Drop legacy CRUD table after parity; Edit crm spec; Commit + rollback** (revert + down-migration).

---

## Task 20: Embed `marketing` (L3 — Axis A,C,D, host tables)

**Wave:** 6 · **Blocks:** T21 · **Blocked by:** T18
**Axes:** A (peer `mail`), C (`ComplianceStore`+`SelfManagedStore`), D (brevo/self-managed). **Tier:** 1. **No module schema** (host tables).

**Files:** Create `integrations/platform/marketing.ts` + test; Modify `apps/zync-api/src/routes/marketing/*` + catalog; Modify marketing spec.

- [ ] **Step 1: Gate-0b** — map zync marketing/catalog surface to module `marketing` (segments/scheduling/automation/sync). Confirm `ComplianceStore`+`SelfManagedStore` (Axis C).
- [ ] **Step 2:** Vehicle + build `marketing` + peer `mail`.
- [ ] **Step 3: Axis-C store impls (FIRST)** — `ComplianceStore` (suppression — enumerate-faithful, silent-`[]` = compliance data-loss) + `SelfManagedStore` over zync Postgres; unit-test enumeration.
- [ ] **Step 4: FAILING swap-parity test** — module segment resolution + self-managed send ≡ host on real audience; suppression honored.
- [ ] **Step 5: Run, verify FAIL.**
- [ ] **Step 6: Wiring file** — register self-managed (+brevo if used) adapter + inject Axis-C stores.
- [ ] **Step 7: Repoint host marketing send/segment to module; DELETE host impl.**
- [ ] **Step 8: parity + suite + typecheck PASS; live campaign send to a test segment with suppression.**
- [ ] **Step 9: Edit marketing spec; Commit + rollback** (no migration).

---

## Task 21: L3 registration

**Wave:** 6g · **Blocks:** T22 · **Blocked by:** T19,T20
**Files:** Modify `index.ts` + barrel.

- [ ] **Step 1:** Wire `registerHelpdesk`/`registerMarketing` into bootstrap.
- [ ] **Step 2:** Full typecheck + suite green; no new WARN.
- [ ] **Step 3:** Commit `feat(migration): L3 registered`.

---

## Task 22: Full integration + live e2e gate + merge

**Wave:** 7 · **Blocks:** — · **Blocked by:** T21 (+ T8, T12, T13)

- [ ] **Step 1:** Full `pnpm install && pnpm turbo typecheck && pnpm turbo test` on the worktree — all green, zero unjustified WARN.
- [ ] **Step 2: Deploy worktree to dev** (host `zc-deploy`: `zync-www`→dev.zync.is, `zync-app`/`zync-api`→app.dev.zync.is).
- [ ] **Step 3: Live e2e on prod-shaped DB** — exercise every embedded capability end-to-end (auth signup/login rehash, tenant cross-isolation 403, uploads R2 presign 200, jobs enqueue→consume idempotent, mail/notifications/realtime send, search tenant-filtered, helpdesk ticket, marketing suppression, tax VAT totals). Code gate cannot see prod schema/secrets — wrangler tail on any 500 (memory lesson).
- [ ] **Step 4: Final gate (advisor-substitute Opus code-reviewer + security-guard)** over the full diff — adapter-minimalism upheld, all host code deleted (no dual paths), every Axis-C store enumerate-faithful, every Axis-B migration parity-verified, swap-parity load-bearing per module, specs edited per §6.
- [ ] **Step 5:** Verify all legacy tables dropped only post-parity; migration journal consistent (zc-dba replay).
- [ ] **Step 6: Merge worktree → master** (host `finishing-a-development-branch`); deploy master to dev; re-run live e2e smoke.
- [ ] **Step 7:** Update project memory (waves shipped, lessons). Tier-3 deferred set documented as platform-donor candidates.

---

## Task 23: [PLATFORM REPO] Extract generic `scheduleValueAt<T>`

**Wave:** P (parallel, platform repo) · **Blocks:** Tier-2 rate-resolution embed only · **Blocked by:** —

> Runs in `/home/user/Projects/platform` via platform's `platform-extract-modules` skill — obeys platform law (Gate-1 delete-test, adapter-minimalism, §4 seam checklist). NOT executed by zync's plan-runner. Listed here as the upstream dependency.

- [ ] **Step 1:** In platform `packages/tax`, extract a generic `scheduleValueAt<T>(schedule: {effectiveFrom: string; value: T}[], date: string): T` from `resolveVatRate`; make the rate type a parameter (remove basis-points hardcoding).
- [ ] **Step 2:** Refactor `resolveVatRate` to call `scheduleValueAt`. Behavioral test: existing VAT resolution unchanged.
- [ ] **Step 3:** Publish via changeset. (zync embeds rate-resolution only after this lands.)

---

## Task 24: [PLATFORM REPO] Harvest zync IL VAT dataset → `tax/rates-table`

**Wave:** P · **Blocks:** Tier-2 rate-resolution embed · **Blocked by:** —

- [ ] **Step 1:** Export zync's authoritative IL VAT rate history (1976–2025) from `packages/db` vat-rates seed.
- [ ] **Step 2:** Add it as the IL dataset in platform `tax/rates-table` (basis-point form). Test: every historical date resolves to the correct rate.
- [ ] **Step 3:** Changeset publish.

---

## Task 25: [PLATFORM REPO] Extract generic Chart-of-Accounts

**Wave:** P · **Blocks:** (future) zync CoA embed · **Blocked by:** —

- [ ] **Step 1:** Harvest zync's generic CoA model (code/type/name) into a platform capability; KEEP Form6111 mapping out (host-side overlay).
- [ ] **Step 2:** Test generic CoA CRUD; document the Form6111-overlay extension point.
- [ ] **Step 3:** Changeset publish.

---

## Self-Review

**Spec coverage:** Tier-1 (16 module tasks T1–T3,T6–T10,T12,T14–T17,T19–T20 + i18n T8) · Tier-2 (auth-hash T13, tax/vat-math T4) · Tier-3 deferred (no tasks — documented in design §3 + T22 step 7) · upstream track (T23–T25) · pilot-first (Wave 1 + gate T5) · expand/contract DB (T10/T12/T14/T19) · Gate-0b suspects (T8 i18n, T12 tenancy-rbac, cache dropped — no task, recorded) · spec edits (every module task step) — all present.

**Cache:** intentionally NO task — design §3/§4 Gate-0b marks it likely no-embed (DB-version keys, no cache layer). Recorded, not omitted.

**Placeholder scan:** parity-test code blocks carry `← replace with real path` markers where the exact host path is resolved by the task's own Step-1 grep (the failing test pins it) — this is a resolve-instruction, not a TBD. All delete-targets, axes, and assertions are concrete.

**Type/name consistency:** wiring files all under `integrations/platform/<mod>.ts` exporting `register<Mod>`; registration tasks (T5/T11/T18/T21) import them; barrel pattern consistent.

**Wave plan check:** every task has Wave/Blocks/Blocked-by. `index.ts` touched only by registration tasks (T5,T11,T18,T21) — each its own serial wave, zero intra-wave overlap. Module wire tasks within a wave touch disjoint wiring files + disjoint host code. T12/T13 same wave: tenancy queries vs PasswordHashDO — disjoint. Security-critical T12/T13 get extra adversarial passes.
