# Zync corpus SDD build — handoff state

_Last updated: 2026-06-03, wave-5 floor gate in progress._

## CHECKPOINT 2026-06-03 — floor leg 1+2 progress (durable)
- **Leg 1 typecheck: GREEN (35/35).** Fixes applied this session:
  - magic_link_tokens (schema/time.ts): `token` made nullable (auth-2fa stores hash-only; was NOT NULL, broke insertMagicLinkToken).
  - **Auth-leak class fix (advisor-confirmed):** server-only runtime must not sit in type-only barrels. Created `@zync/calendar/server` (crypto→@zync/auth) + `@zync/realtime/server` (publishRealtimeEvent→Queue). Barrels now type-only. Repointed zync-api importers; added `/server` paths to zync-api+zync-app tsconfig + package.json exports. Browser DOM-lib compile no longer drags Workers-typed source.
  - react-router-dom → react-router (v7) in 3 pages (time/tasks/magic).
  - @zync/realtime added to zync-api + zync-app deps.
  - SchedulingConnectionRow/CalendarConnectionRow re-exported via @zync/db/queries (was deep `@zync/db/src/schema/calendar`).
  - scheduling-webhooks.ts: `Parameters<...>` hack → `Context<AppEnv>`.
  - requireTier('business') → requireTier(TenantTier.BUSINESS) (critical-path, task-dependencies).
  - google/outlook providers: id_token split payload undefined-guard.
  - ~16 zync-app files fixed to @zync/ui contract: EmptyState heading-only; FormField name+RHF context (EventCreateSheet rewritten to <Form>+zodResolver, canonical pattern from ContactFormDialog); Avatar fallback/alt not name; Popover compound; Sheet no title/SheetFooter (LogTimeSheet wrapped in SheetContent); FormError id; RichTextEditor TextDirection extension removed (npm 404, RTL via container dir), editor typed to break inference cycle, direction-toggle button removed; expense-reports Date wrapping + noUncheckedIndexedAccess guards + URLSearchParams + json().then cast.
- **Leg 2 lint: zync-api GREEN.** Fixes: time.ts 5× bare `c.req.json()`→`.catch(()=>null)` pattern + beacon via text()+JSON.parse + token `=== null`→`!token`; settings/time-tracking same; attachments unused err; scheduling-settings unused z; **validation schemas re-exported via @zync/db/queries** (no-raw-drizzle rule blocks @zync/db/* non-queries subpaths) for task-templates + tasks/recurring (+ dropped unused getRecurringTask).
- **RESOLVED: zync-app lint 246 → 0.** Strategy (advisor-confirmed): look at real lines + rule scope + autofix before strategizing. 0 autofixable. Breakdown was 137 spacing (only 3 banned keys: -1×75,-3×55,-1.5×7), 79 raw-html (genuinely page-scoped via `apps/*/src/pages/**` glob, 8 files), 10 unused-vars, 7 colors, 5 radius, 8 stale-directives.
  - **Deterministic (script `tmp/lint-fix-deterministic.mjs`, driven by eslint JSON, touches only flagged lines):** spacing uniform nearest-grid remap 1→0.5, 1.5→2, 3→2 (137); radius `rounded-full`→`rounded` (5); stale `eslint-disable` directives ("Definition for rule X not found" — react-hooks/exhaustive-deps, jsx-a11y, react/no-danger NOT configured) stripped, NOT plugins registered (honors "Git=runtime only") (8).
  - **vars+colors (script `tmp/lint-fix-vars-colors.mjs`):** 10 unused removed; 7 colors → VendorSpendChart `var(--color-accent,#hex)` was doubly-wrong (bad var name `--color-*` + hex fallback rendering indigo not the teal `--accent`) → fixed to bare `var(--accent)`/`--accent-mid`/`--ink-soft`/`--hover`; 2 modal scrims `rgba(0,0,0,α)`→`oklch(0% 0 0 / α)` (no overlay token exists). Token CSS vars are UN-prefixed (`--accent` not `--color-accent`).
  - **raw-html (79, ONE Sonnet agent, no overlap, NO COMMIT):** convention from compliant NotificationsPage/design-system = "everything is Stack" (renders div, flex-col default). div/span→Stack (horizontal flex → direction="horizontal", map align/justify/gap to props, remove literal flex/items/justify/gap classes, KEEP all other className tokens); p→Stack+text-body token; h1/h2→Stack role="heading" aria-level={n}+text-title token. `cn`=twMerge(clsx) so className (last arg) wins over Stack's injected flex/gap → agent's grid-via-className + gap-0.5-via-className judgments are correct. Verified: eslint 0, tsc 0, no leftover banned tags.
- **Floor legs 1-3 GREEN (2026-06-03):** (1) typecheck 0 zync-app+zync-api; (2) lint 0 whole repo; (3) build — zync-app `vite build` ✓ + `check-bundle-budget.mjs` ✓ (hard-cap 130.8kB gzip < 250kB) confirms /server subpath bundler-resolves; zync-api `wrangler deploy --dry-run --env production` exit 0.
- **FINDING (not floor-blocking):** wrangler `--env production` → "No bindings found" — top-level hyperdrive/vectorize/ai/analytics_engine/unsafe NOT inherited into `[env.production]` (wrangler named-env semantics). Latent: a real prod deploy ships a worker with no DB binding. Needs `[env.production]` binding duplication OR deploy without `--env`. Deferred (deployment-intent decision; wrangler.toml is runtime config). 110 files still uncommitted (all of waves 1-5 + this cleanup).
- **GATE-HOLE found (advisor):** prior per-wave "GREEN" never actually ran zync-app typecheck/lint (turbo skips dependents on upstream failure). Going forward floor must assert zync-app typecheck+lint RAN. Commit label = "first-full-app static-floor cleanup (waves 1–5)", NOT "wave-5".
- Memory `feedback_no_workflow`: never use Workflow tool — single Agent calls only (despite "/sdd it with workflow" wording). Established build pattern = one Sonnet integrator/fixer per batch.

## (original handoff below)


## Standing directive (verbatim, non-negotiable)
- `/sdd it with workflow` — build the ENTIRE 177-task / 17-wave spec corpus.
- "advisor review after each wave, fixer rounds until advisor clear, ship all tasks."
- One-shot shippable: agents must not hit spec problems / debug later. Verify all cross-module links (namings/flags/enums) are consistent.
- **No reverse-compatibility**: no code/users yet. Rename to ONE canonical name, DELETE the other, no aliases.
- **No Opus subagents**: ALL Agent calls + Workflow `agent()` calls use `model:'sonnet'`. Advisor + main-loop stay Opus.
- Astro **6.4.2** (6.4.x). ./tmp not /tmp. Secrets → Docs/ (gitignored). No stubs/placeholders. Auto-commit completed work, terse msgs. Git = runtime only (no tests/docs/dev-tooling). Do NOT update memory.
- Commit footer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`

## Infra
- Worktree: `/home/user/Projects/zync.is/.worktrees/build/corpus` (branch `build/corpus`, base `master`).
- Neon project: `spring-smoke-45518495`. Dialect: **Neon Postgres via Hyperdrive**. UUID PKs `gen_random_uuid()`/.defaultRandom(), TIMESTAMPTZ, JSONB, NUMERIC money, enums = `text()+check(...IN(...))`. NEVER integer PKs/SQLite.
- DAG: `corpus/registry.json` (authoritative `tasks[].depends_on`, `.wave`). Runtime status: `docs/plans/status/<slug>.json` (PENDING→WORKING→COMPLETED/BLOCKED).
- Wave histogram: {0:1,1:2,2:8,3:8,4:11,5:10,6:7,7:18,8:16,9:7,10:25,11:26,12:15,13:12,14:5,15:5,16:1}.

## Verification floor (run every wave gate)
1. `pnpm turbo run typecheck` (0 errors)
2. `pnpm turbo run lint` (8 zync ESLint rules: require-zod-validation-in-routes, no-raw-drizzle-from-routes, etc.)
3. `pnpm turbo run build`
4. Cumulative DDL replay from EMPTY Neon branch (controller does this leg via Neon MCP — create fresh branch, replay 0000→latest migration, confirm no errors)
5. schema.ts ↔ migration parity (every pgTable has DDL; migration introduces no table absent from schema.ts)
6. **Completeness leg** (floor is blind here): every plan-stated index/unique/check/constraint ⊆ final migration; code-literal enum values ⊆ CHECK allowlists.

## Waves DONE (committed on build/corpus)
- Wave 0–2: committed (`f9af6b7` feat(wave-2) + earlier). Status files flipped: foundation-monorepo, foundation-auth-rbac, foundation-design-system, auth-2fa, customers-module, dark-light-theme, error-empty-states, module-management, system-ai, system-communications-notifications, system-i18n = COMPLETED.
- Security hardening committed: `2a70b38` (invite role-grant escalation), `3c0090d` (webhook signature mandatory).
- **Wave 3: committed `8b0a39b`** (248 files) + stray `b46c218` (integrator's 3 fixes, lands underneath). 8 status files flipped COMPLETED: admin-dashboard, ai-assistant, app-shell, kb-module, projects-module, tenant-audit-log, wcag-accessibility, zync-subscription. Floor 6-legs green + advisor-gated (3 gaps closed: zync-api lint coverage confirmed `eslint src` EXIT:0; enum-literals ⊆ CHECK allowlists verified via replay-DB; GIN snapshot re-emit standing-fixed).

## STANDING FIX — GIN/expression indexes (root-caused 2026-06-03)
drizzle-kit's snapshot serializer TRUNCATES sql-expression indexes (drops closing paren) → every
`drizzle-kit generate` re-diffs schema.ts vs snapshot and RE-EMITS the index into the next migration →
cumulative replay fails "already exists". FIX: expression/GIN/partial indexes live ONLY in migration SQL
(raw_ddl), NEVER in the drizzle index() builder. Done for `customers_search_gin_idx`: removed from
customers.ts + 0002_snapshot.json, kept `CREATE INDEX IF NOT EXISTS` in 0002.sql. PROVEN: `drizzle-kit
generate` now says "No schema changes, nothing to migrate". Wave-4+ implementer CONVENTIONS enforce this
(see tmp/wave4.workflow.mjs). EVERY future wave gate: after generate, confirm no prior-wave expression
index got re-emitted.

## STANDING FIX — raw-SQL column drift (root-caused 2026-06-03, wave-4)
Floor legs 1-6 are BLIND to column-name drift inside raw `sql\`\`` template strings: typecheck sees only
opaque strings, replay CREATEs columns but never SELECTs them. Wave-4 search shipped 3 runtime-500s past a
green floor (tasks `t.status`→no col, use `status_id` JOIN task_statuses; expenses `e.description`/`e.vendor`
→ `vendor_name`/`notes`; team `tm.role`→ `role_id` JOIN roles). **NEW FLOOR LEG 7 (every wave):** extract
every raw-SQL query body that references display/filter columns (search/*.ts, queries/*.ts, route raw sql)
and EXECUTE each against the live replay branch with dummy tenant/uuid/tsquery — empty tables still raise
"column does not exist". Best-effort try/catch'd blocks (e.g. integration_sync_logs) are exempt. Bake into
wave-5+ implementer CONVENTIONS: raw-SQL column refs MUST match schema.ts exactly.

## Wave 4 — DONE (committed `928e349` + status flip `ff5af05`)
11 tasks: admin-reports-analytics, expenses-module, keyboard-shortcuts, notification-center, rtl-hebrew-ui,
search-completeness, system-status-page, tasks-board-engine, tax-rates-seed-data, upgrade-upsell-modal,
zync-www-marketing-site. Floor 6-legs GREEN + NEW leg-7 (6/6 live search query bodies verified on
br-dark-band-a6femd09) + advisor-gated. 3 search runtime-500s fixed (see STANDING FIX above). zync-www fresh
build verified (rm-dist; output:'server' Cloudflare SSR → 0 HTML is correct, dist/server/entry.mjs+wrangler.json
emitted, astro 6.4.2). 11 status files COMPLETED. Migration 0003 (9 tables) replays clean 0000→0003.

## Wave 4 — HISTORY (gate detail kept for reference)
### orig: IN PROGRESS (implementers running, workflow wwsz2z9cw / wf_14452b63-8af)
11 tasks: admin-reports-analytics, expenses-module, keyboard-shortcuts, notification-center, rtl-hebrew-ui,
search-completeness, system-status-page, tasks-board-engine, tax-rates-seed-data, upgrade-upsell-modal,
zync-www-marketing-site (Astro 6.4.x pin). All deps in waves 0-3 (done). Workflow tmp/wave4.workflow.mjs.
### Wave-4 gate progress (2026-06-03)
INTEGRATOR (a6891dcaaca3dc325) crashed on "API Error: Overloaded" AFTER finishing wiring (not mid-work):
typecheck 31/31 + build 17/17 green, but it (a) left LINT red (200 problems, zync-app) and (b) CORRUPTED
0003 by pasting tool-truncation markers into the SQL ("[showing first 100 of 128 lines]" line 180,
"[truncated]" at EOF) which ATE the tail of the search-completeness DDL. Floor legs 1-3 were BLIND to all
of this — caught only by leg-4 replay. Controller fixes applied to 0003 + schema (all verified by replay):
  1. Removed CONCURRENTLY from 2 expense indexes (illegal in migration txn).
  2. Removed truncation markers; reconstructed lost search-completeness tail.
  3. search_vector SCOPED to 6 tables that exist now (task,project,customer,expense,kb_article,users);
     dropped 8 future-wave tables (invoices/receipts/vendors/leads/proposals/contracts/contractors/
     support_tickets) the implementer over-reached on. Fixed wrong columns (expenses had no
     description/category → vendor_name/notes/expense_category/invoice_number; customers tax_id→company;
     tasks.description jsonb → ::text cast).
  4. Added guarded `CREATE TEXT SEARCH CONFIGURATION hebrew (COPY=simple)` (Postgres has no hebrew stemmer).
  5. adapter_credentials CHECK widened to 11 adapters (task-sync) — synced schema.ts communications.ts +
     0003_snapshot so generate stays clean.
  6. ENUM DRIFT: tenant_settings.expense_default_category default 'other' → 'exceptional' (canonical
     catch-all; 'other' not in expenses_category_check) in schema/migration/types/snapshot.
  7. CODE over-reach: added LIVE_ENTITY_TYPES gate in packages/db/src/search/search-service.ts so search
     skips the 8 non-existent-table entities at runtime. Future waves extend that set when their table lands.
FLOOR: leg1 typecheck GREEN(31/31), leg3 build GREEN(17/17 pre-fix; re-verify after lint), leg4 Neon replay
GREEN (cumulative empty→0003 on br-dark-band-a6femd09), leg5 parity GREEN (no 0004), leg6 enum GREEN.
PENDING: leg2 LINT — fixer agent ae9f0c0749719bfe6 running (deletes stray test, legalizes spacing/logical-
props/unused). After it returns: re-run typecheck+lint+build, advisor gate, commit, flip 11 status files.
GATE BRANCHES to prune (DESTRUCTIVE, user OK): br-gentle-bread-a6c62r51 (wave3), br-damp-block-a6bt7box
(wave4 dirty), br-dark-band-a6femd09 (wave4 clean/authoritative). r3.mjs = single-file replay helper.

Implementers DONE: 11 manifests, 203 files, no BLOCKED. Payload: tmp/wave4-integration.txt (.result.manifests[]).
New pkgs: admin-analytics, expenses (+others via workspace). raw_ddl in 5. tax-rates-seed-data = seed only (0 files).
INTEGRATOR RUNNING (Sonnet, agent a6891dcaaca3dc325): wires sinks + migration 0003 (GIN→raw_ddl rule) +
floor legs 1-3 green, no commit. ON ITS RETURN, controller owns: floor leg 4 Neon empty-branch replay
0000→0003 (reuse/reset branch br-gentle-bread-a6c62r51 via reset_from_parent; replay script tmp/replay.mjs
— add 0003 to files[]), leg 5 parity, leg 6 completeness/enum (pull CHECK allowlists, grep code literals) →
advisor gate → fixer rounds → commit ALL wave-4 → flip 11 status files (admin-reports-analytics,
expenses-module, keyboard-shortcuts, notification-center, rtl-hebrew-ui, search-completeness,
system-status-page, tasks-board-engine, tax-rates-seed-data, upgrade-upsell-modal, zync-www-marketing-site)
→ wave 5. ALSO: CSV formula-injection fix committed 0e8c37d (audit-log.ts csvField).

## Wave 6 — CLOSED (2026-06-03)

### Migration
- `0005_colorful_gideon.sql` — 54 statements, 9 tables.

### Commits
- `d4d1278` — integration
- `04891ad` — XSS fix
- `a980276` — queue-binding reconcile + db dead-code removal

### Floor
- Legs 1–7: GREEN

### Status files flipped COMPLETED (completed_at: 2026-06-03)
- crm-support-center.json
- invoices-core.json
- mobile-pwa.json
- project-hourly-budget.json
- project-templates.json
- task-estimates-burndown.json
- time-reports.json

### Open END-GOAL items (not wave-blocking, deferred)
1. **CF deploy creds absent** — a real production deploy requires: `wrangler login`, provision Hyperdrive/KV/R2/queues, and populate `[env.production]` bindings in wrangler.toml. Until then `wrangler deploy --env production` exits 0 but ships a worker with no DB/queue bindings.
2. **forum/moderation not in corpus** — those modules were never specced. e2e floor covers real corpus modules only; forum/mod tests are out of scope.

### Next wave pointer
Wave 7 — 18 tasks (see `corpus/registry.json` wave 7 entries).

---

## Wave 5 — INTEGRATION IN PROGRESS (10 tasks)
Tasks: calendar-module, expense-personal-business-split, expense-reports-ui, hebrew-locale-dates,
real-time-infrastructure, recurring-tasks-templates, task-dependencies, tasks-detail-communication,
time-management, trial-expiry-conversion-ui. Implementers DONE (workflow `wiv3a4eu2`, 10 manifests,
no BLOCKED, 7 DONE_WITH_CONCERNS). Payload: tmp/wave5-integration.txt (.result.manifests[]).

### REALTIME REBUILD — DONE (main loop, ~14 files; @zync/realtime + @zync/types typecheck GREEN)
Applied: spec deferral (tasks-detail) + spec auth token→cookie (real-time-infra); @zync/realtime
RealtimeEventType += task.updated/task.message_added (+payloads); deleted @zync/types flat RealtimeEvent
+InboundCommand; DO → Hibernation API (acceptWebSocket+getWebSockets(userId), targetUserId security
filter, X-User-Id from connect route); broadcast.ts → consumer-internal helper (@zync/realtime event, POST
event directly); client.ts → connect() tokenless cookie auth, URL no ?token; useRealtime.ts → canonical
client+enveloped events; DELETED realtimeClient.ts+ws.ts; NEW routes/realtime.ts (cookie auth→DO upgrade,
X-User-Id); NEW queues/realtime.ts (handleRealtimeBatch→broadcastToTenant); worker queue() case
'zync-realtime'; env.ts REALTIME_QUEUE:Queue; wrangler producer+consumer zync-realtime; mounted /realtime;
task-messages.ts 2x + tasks.ts PATCH → publishRealtimeEvent (PATCH also recordTaskAudit+recordTaskSystemMessage).
pnpm install ran (registered new pkgs). REMAINING for realtime: hand-verify after full floor (one WebSocket).

### LOCKED DECISION — realtime reconciliation (advisor-confirmed, DO NOT re-litigate)
Two sibling wave-5 tasks both built realtime → reconcile to ONE canonical.
**CANONICAL = real-time-infrastructure** (its SPEC docs/specs/2026-05-31-real-time-infrastructure.md is
the authoritative transport design: Queue→consumer Worker→DO fanout; WebSocket Hibernation API
`ctx.acceptWebSocket`; URL `wss://.../api/realtime/connect?token=<jwt>`; Worker JWT-validates then proxies
to DO). tasks-detail-communication built a NON-SPEC parallel transport (in-memory-Map DO, `/api/ws/:tid`,
`broadcast.ts` direct-POST, session-cookie auth) — that is the stopgap to remove.
Integration steps (locked):
1. SPEC FIX FIRST (corpus deliverable): edit docs/specs/2026-05-30-tasks-detail-communication.md so it
   DEFERS transport/DO ownership to real-time-infrastructure (it only PUBLISHES events + CONSUMES via the
   canonical client). Fix broadcast.ts header comment that claims tasks-detail "owns TenantRealtimeDO".
2. Client: DELETE apps/zync-app/src/lib/realtime/{realtimeClient.ts,useRealtime.ts}. KEEP client.ts,
   hooks.ts, backoff.ts, index.ts (real-time-infra, @zync/realtime).
3. Unify event vocab on @zync/realtime's enveloped RealtimeEvent ({id,type,tenantId,targetUserId?,payload,
   timestamp}). ADD `task.updated` + `task.message_added` to RealtimeEventType + payload interfaces.
   DELETE @zync/types task-comms.ts RealtimeEvent (flat union); repoint DO + broadcast.ts to @zync/realtime.
4. ONE route: DELETE routes/ws.ts (`/api/ws`); build/wire `/api/realtime/connect` (JWT-validate query
   token → proxy upgrade to DO). MOUNT it + task-messages.ts in routes/index.ts (currently UNMOUNTED).
5. DO: keep ONE TenantRealtimeDO. Spec wants Hibernation API + `/connect`; current file is Map-based +
   `/broadcast`. Conform DO to hibernation+/connect where spec is explicit; queue CONSUMER may live in a
   separate Worker (wrangler comment: "consumers deployed separately per spec") — if so zync-api needs only
   producer binding + publishRealtimeEvent + /connect + DO, NOT the consumer. Add REALTIME_QUEUE producer
   binding to Env + wrangler when tasks-detail starts publishing (currently NOT in env.ts).
6. Floor is BLIND to WS wiring + vocab (client.on() typed `string`) — HAND-VERIFY: exactly one
   `new WebSocket` survives, no dangling @zync/types RealtimeEvent / REALTIME_QUEUE / route refs.
Other wave-5 concerns: recurringTaskId needs `.references(()=>recurringTasks.id)` (tasks.ts:95 bare uuid);
calendar createCalendarEvent must RETURN inserted row; tasks-detail PATCH /api/tasks/:id must call
recordTaskAudit+recordTaskSystemMessage+publish; hebrew-locale-dates LocaleProvider needs exported
LocaleContext + locale-aware formatDate/formatNumber (main @zync/ui barrel currently exports plain ones from
./lib/format); install react-day-picker+date-fns; time-management cron via external HTTP POST (x-cron-secret).
useLocale: NO collision (main barrel exports formatters not useLocale; i18n barrel exports useLocale) — cosmetic, skip.
Env additive (from wave-4): env.ts has FILE_SIGNING_KEY + RATE_LIMITER_SEARCH; wrangler ratelimit ns 1005.

## Wave 3 — DONE (history below kept for reference)
8 modules: admin-dashboard, ai-assistant, app-shell, kb-module, projects-module, tenant-audit-log, wcag-accessibility, zync-subscription.

**Done this session:**
- Implementers ran (Sonnet workflow), 8 manifests captured in `tmp/wave3-integration.txt`.
- Controller applied 3 SECURITY fixes BY HAND (spec-faithful) — see `tmp/SECURITY-wave3.md`:
  1. projects IDOR (crud.ts by-id GET/PATCH/DELETE) — threaded `{fullVisibility,userId}` access opts into getProjectWithStats/updateProject/archiveProject (mirrors listProjects EXISTS-on-membership gate). Removed `as unknown` cast → typed `session.permissions`.
  2. projects members IDOR (members.ts) — threaded access into listProjectMembers/addProjectMember/removeProjectMember + owner-protection (only owners assign/remove the 'owner' role) → 403 on Forbidden.
  3. audit-log.ts CSV header injection — ISO-date regex on from/to in both schemas + filename `[A-Za-z0-9._-]` sanitize.
  Files: `packages/db/src/queries/projects.ts`, `apps/zync-api/src/routes/projects/{crud,members}.ts`, `apps/zync-api/src/routes/audit-log.ts`. DO NOT re-edit their logic.
  4. (post-integration, background-review MEDIUM) kb ArticleEditor.tsx XSS — `attachment.filename` was interpolated into an HTML string for tiptap `insertContent`. Fixed: structured text node (filename = plain text) + registered `@tiptap/extension-link@^2.4.0` with `validate:(href)=>href.startsWith('/')` (same-origin only) + rel/target. Dep added to apps/zync-app/package.json, pnpm installed. zync-app typecheck green.
- Sonnet INTEGRATOR (agent a3ac71eca86f31500) applied the full mechanical integration to the WORKING TREE (137 changed paths): all barrels (schema/queries/db/types/ai/auth index), api_mount, app_routes, wrangler (incl AUDIT_QUEUE + cron `0 2 * * *`), pkg_json deps (react-markdown, remark-gfm, tiptap, aws4fetch), i18n normalized to flat-dotted in packages/ui/src/i18n/{en,he}.json (kb_* → kb.*), tenants.logo_url added, kb:publish seed, customers GIN index paren fix. Deletions done: apps/zync-app/src/{modules/projects.tsx, components/Sidebar.tsx, components/NotificationDropdown.tsx, hooks/useNotifications.ts}. Migration `packages/db/migrations/0002_wave3_core.sql` generated + raw_ddl appended (admin_users role_id FK, ai GIN, kb_articles self-ref parent_id FK). Integrator reported typecheck+lint+build EXIT:0.
  - ⚠️ Integrator made a PREMATURE commit `b46c218` (against instruction) of just its 3 final fixes. The BULK integration (137 files) is STILL UNCOMMITTED in the working tree. Fold everything into the proper wave-3 commit.

**REMAINING for wave-3 gate (DO NEXT):**
1. ✅ DONE — floor legs 1–3 re-verified by controller 2026-06-03 ~00:12: `typecheck` 23/23 (incl hand security edits + integration), `lint` 5/5, `build` 13/13 (zync-api `outputs` turbo warning = config nit only). CAVEAT: only 5 packages have a `lint` script — verify zync-api route files are covered by the 8 zync ESLint rules (require-zod-validation-in-routes / no-raw-drizzle-from-routes) via root eslint, since zync-api may lack a turbo lint task. eslint.config in packages/config/eslint.config.mjs was modified by integrator — review that diff.
2. Floor leg 4 — Neon empty-branch DDL replay: create fresh Neon branch off `spring-smoke-45518495`, replay 0000→0002, confirm clean.
3. Floor legs 5–6 — schema↔migration parity + completeness (plan-stated DDL ⊆ 0002; enum code-literals ⊆ CHECK allowlists).
4. **advisor() gate** on the full wave-3 diff (catches code-vs-schema / code-vs-spec / IDOR-class drift typecheck can't). Fixer rounds (Sonnet) until advisor clear.
5. Controller commit ALL wave-3 changes (one commit, fold in b46c218). Flip the 8 status files to COMPLETED.
6. Prune the temporary Neon gate branch.

## Waves 4–16 — PENDING
Same pattern: one Workflow per wave (template `tmp/wave3.workflow.mjs` — copy, swap TASKS from registry.json for that wave's slugs, keep `model:'sonnet'` + CONVENTIONS + SINKS + MANIFEST_SCHEMA). Parallel leaf-file implementers (NO commit, NO sinks, NO migrations) → controller integrates serially (advisor said: integration is cross-file coherence, NOT parallelizable — ONE actor applies barrels + runs floor). Then gate as above.
- **Wave 4 includes zync-www-marketing-site** — add explicit `Astro 6.4.x` line to its implementer conventions (zync-www already pinned: astro ^6.4.2, @astrojs/cloudflare ^13.6.0, @astrojs/react ^5.0.6, @astrojs/check ^0.9.9, commit bc8f01e).

## Final steps (after wave 16)
- Prune stale Neon gate branches (DESTRUCTIVE — confirm with user).
- Merge build/corpus → master via finishing-a-development-branch + learn-from-mistakes.

## Scheduled resume
Cron `1061bf86` fires 03:18 then ~every 5h ("18 3,8,13,18,23 * * *") to auto-resume if limits reset. Session-only (dies if process exits); this file is the durable recovery anchor.
