# Handoff: Technical Context (as-built state, gotchas, and design directions)

**Written:** 2026-07-12 by Claude Fable 5, anticipating loss of Fable access.
**Audience:** future AI sessions (Opus 4.8 / Sonnet) + James.
**Companions:** `docs/foundation/02-kernel-spec.md` (living spec; §9 items 10–17 are the build decisions), `docs/handoff/10-business-context.md` (strategy), repo-root `CLAUDE.md` (standing instructions). This file is *knowledge*, not instructions.

---

## 1. As-built state (verified working, 2026-07-11)

Everything below was **verified end-to-end against live Claude**, not just typechecked: grounded chat answers (correct prices; refused a Sunday booking per grounding), tool-created booking (Party linked, audit written), operator confirmation (RBAC transition), pg-boss reminder scheduled at T−30min and delivered back into the chat, per-call usage metering, RLS isolation (cross-tenant read = 0 rows, cross-tenant write = rejected), two vertical skins over one module, 20 tests + 3 typechecks green.

```
apps/demo                               Next 16 app (port 3100): chat UI, Better Auth login,
                                        scaffolded admin (list/detail/transitions/audit),
                                        /api/chat, seed/worker/confirm-latest scripts,
                                        two manifests (wellness default, tutoring via DEMO_MANIFEST)
packages/core         @factory/core     THE KERNEL — src/: config, errors, db/, tenancy/, data/,
                                        events/, jobs/, ai/, channels/, sdk/, testing/
packages/modules/scheduled-interactions  demo module: booking machine, conversation-scoped tools,
                                        webchat binding, reminder job, Flow A integration test
packages/config                         shared tsconfig.base.json
```

**As-built delta 2026-07-12 (kernel v2 + factory; see spec §9.18–9.22 and `30-session-journal.md` §3):**

```
packages/core/src/data/refs.ts            EntityRef + entityRef/resolveRef/assertKnownRefType
packages/core/src/sdk/module.ts           ModuleDef v2: dependsOn, emits, permissionNamespaces,
                                          ui { nav, dashboardWidgets, recordTabs } (descriptors+loaders)
packages/core/src/sdk/manifest.ts         modules[], roles bundles, branding.theme + resolveTheme
packages/core/src/sdk/registry.ts         topo-sort, boot validations (deps/perm-ns/events/UI/
                                          manifest set-equality/dup channel binding), runtime.registry
packages/core/src/sdk/registry.test.ts    12 pure composition-validation tests
packages/core/src/db/migrate.ts           also applies packages/modules/*/migrations (tracked <mod>/<file>)
packages/modules/sales-documents          Flow B module: quote+invoice machines, documents.ts,
                                          bookingInvoicingBridge (bridge-module pattern), flow-b.test.ts
packages/modules/registry.ts              STATIC metadata for the composer (not imported at runtime)
packages/factory      @factory/composer   pnpm factory:create-app — codegen from the demo template
apps/demo/lib/{pricing,theme}.ts          vertical price hook (app layer); theme tokens → CSS vars
apps/demo/app/admin/page.tsx              dashboard rendering module widgets
apps/demo/app/admin/[entity]/[id]         renders contributed record tabs
apps/demo/scripts/transition-latest.ts    demo op: transition newest record of a type
apps/barber-demo                          composer output example (regenerable; own tenant slug)
docs/handoff/modules/                     authoring guide + contract gaps + 13 module work orders
docs/handoff/{00-START-HERE,30-session-journal,40-verification-playbook}.md
```

Key seams and where they live (paths relative to `packages/core/src/`):

- `db/client.ts` — `adminDb()` (owner; migrations/seed/jobs only), `appDb()` (restricted role; ALL runtime access), `withTenant(tenantId, fn)` opens a transaction and sets the `app.tenant_id` GUC that every RLS policy checks. `DbTx` is the tx handle type carried in ActionContext.
- `tenancy/context.ts` — `ActionContext { tenantId, userId?, permissions, tx, runtime }`; `KernelRuntime { bus, jobs, gateway, retriever, tracer, channels, vertical }`; `runWithContext()`. `VerticalConfig` gives modules `label()`/`prompt()` resolution with manifest overrides.
- `tenancy/rbac.ts` — `hasPermission` with `*` and `prefix.*` wildcards; `requirePermission` throws typed `PermissionDeniedError`.
- `data/` — `records.ts` (`createRecord`/`transitionRecord` validate the StatusMachine, write audit, publish `record.<type>.created|transitioned`, then run `onEnter` — all inside ctx.tx), `parties.ts` (incl. `upsertPartyByContact` keyed on contact JSONB containment), `status-machine.ts`, `audit.ts`, `money.ts` (minor units).
- `events/bus.ts` — in-process, exact/`prefix.*`/`*` matching, handlers run **inside the publisher's transaction** (failure rolls the whole action back — deliberate walking-skeleton semantics; an async/outbox tier is future work when a module needs non-transactional side effects).
- `jobs/jobs.ts` — pg-boss v12 wrapper, schema `kernel_jobs`, connects as ADMIN role (trusted infra); handlers get a fresh RLS-scoped ActionContext built from `payload.tenantId` with the JobDef's declared permissions. `startWorker(defs, runtime)`; dev runs the worker inside Next via `apps/demo/instrumentation.ts`.
- `ai/gateway.ts` — all AI types: `LLMGateway` (chat/object/embed), `AIContext { ctx, purpose, model?, maxTokens? }`, `ProviderAdapter` (the seam a new provider implements: owns its native message format AND its tool loop, calls back a `ToolExecutor`), `Retriever`, `Tracer`.
- `ai/llm.ts` — `KernelGateway`: resolves adapter by provider (default = first registered provider + `KERNEL_MODEL_DEFAULT`), serializes tools, builds the permission-checked executor, meters to `ai_usage` (inside ctx.tx — usage is lost if the tx rolls back; known accepted tradeoff), traces start/end.
- `ai/anthropic.ts` — official `@anthropic-ai/sdk`; adaptive thinking; echoes full assistant content blocks (thinking included) between tool rounds; handles `pause_turn`; `object()` via `output_config.format json_schema`; `embed()` throws NotImplemented (Anthropic has no embeddings endpoint).
- `ai/tools.ts` — `ToolDef<TInput = any>` (any is REQUIRED: run() is contravariant in TInput; `unknown` breaks collections), `toolJsonSchema` = zod v4 `z.toJSONSchema` + `closeObjects` normalizer (recursively sets `additionalProperties:false`, fills `required`, strips `$schema`) — required for Anthropic schema rules.
- `ai/retriever.ts` — `LexicalRetriever` (pg_trgm `word_similarity`/`similarity`, schema-qualified as `public.*`); `PgVectorRetriever` throws NotImplemented (see §5).
- `channels/` — `Channels` registry; `ChannelAdapter { channel, send, recordInbound? }`; `WebChatAdapter` persists both directions to `channel_messages`; `conversationAsChatMessages()` folds the log into merged chat turns (the conversation log IS the bot's memory — no separate session state).
- `sdk/module.ts` — `defineModule`: entities (EntityDescriptor: type, labels, machine, dataSchema, listColumns → drives scaffolded UI), permissions, aiTools, jobs, events, channels (ChannelBinding with `permissions` granted to inbound context).
- `sdk/manifest.ts` — `VerticalManifest` (zod-validated): module, locale, branding, labels, prompts, grounding, workflow overrides (transitions-only; `onEnter` behavior stays module code), customFields.
- `sdk/registry.ts` — `createKernel({modules, manifest, providers?, channelAdapters?, retriever?, tracer?})` → `KernelApp` with `runAsUser`/`runAsSystem`/`handleInbound`/`startWorker`/`machineFor` (manifest workflow override merged over module machine)/`entityLabel`.

**The conversation-scoped tools pattern** (module `tools.ts`): tools that act for a customer are built per inbound message with the conversation handle bound by closure — the model never supplies identity, so it cannot spoof another customer. Keep this pattern for every customer-facing tool in every future module.

## 2. Environment facts (NOT in the repo; do not lose)

- Postgres is a **remote shared server** (PostgreSQL 18.4, Ubuntu, host/creds in `.env`), `firstdb` full of James's other projects. Kernel tables live in schema `kernel`; pg-boss in `kernel_jobs`. Never touch other schemas/tables.
- `.env` `POSTGRES_USER` is a **superuser** → bypasses RLS entirely. That is why the restricted role `ai_kernel_app` exists (password: `.env` `APP_DB_PASSWORD`; URL: `APP_DATABASE_URL`); `pnpm db:migrate` provisions/re-syncs it idempotently. RLS policies are ENABLE + FORCE on business tables only; identity tables (`tenants,users,roles,user_roles`) and `ba_*` (Better Auth) are deliberately outside RLS.
- **pgvector 0.8.5 became available on the host 2026-07-11** (James installed `postgresql-18-pgvector`), but nothing in code uses it yet — activation checklist in §5. `pg_trgm` is installed and used.
- The Anthropic key is in `.env` as **`CLAUDE_API`** (nonstandard name; `config.ts` accepts it or `ANTHROPIC_API_KEY`).
- Dev URL http://localhost:3100. Seeded tenants: `demo` (Lotus Wellness Studio) and `tutoring-demo` (Bright Steps Tutoring; select via `DEMO_MANIFEST=tutoring DEMO_TENANT_SLUG=tutoring-demo`). Operator login printed by `pnpm db:seed` (owner@demo.local / demo-owner-2026 — demo-only credentials).
- Versions that matter: Node 24 (native `process.loadEnvFile`), pnpm 11 (corepack), TypeScript **7** (native compiler), Next 16.2 (Turbopack), React 19.2, zod **4**, drizzle 0.45, pg-boss **12**, Better Auth 1.6, Tailwind 4, `@anthropic-ai/sdk` 0.110.

## 3. Sharp edges discovered during the build (each cost real debugging time)

1. **Extensionless relative imports are the repo convention.** Turbopack's instrumentation pipeline cannot resolve `./x.js`-style specifiers back to `.ts` sources; everything was converted. Never reintroduce `.js` suffixes in relative imports.
2. **Modules must NOT depend on `drizzle-orm` directly.** pnpm peer-dedup created two type-incompatible drizzle instances (`_pg` vs `_kysely` peer variants). `@factory/core` re-exports `sql, eq, and, or, desc, asc, inArray` — modules import those.
3. **pg-boss v12:** named export `{ PgBoss }`; `createQueue(name)` required before `send`; `work` handler receives a jobs **array**. For manual testing, direct `INSERT INTO kernel_jobs.job (name, data)` works.
4. **Better Auth + drizzle adapter:** pass explicit `schema: { user: baUsers, session: baSessions, account: baAccounts, verification: baVerifications }`; camelCase TS properties mapping to snake_case columns is fine. Server-side signup: `auth.api.signUpEmail({ body })`. Next route: `toNextJsHandler(auth.handler)` in `app/api/auth/[...all]/route.ts`.
5. **Next 16:** `params`/`searchParams` are Promises (must `await`); `next.config.ts` is ESM (no `__dirname` — use `fileURLToPath(import.meta.url)`); root `.env` is loaded via `process.loadEnvFile` at the top of `next.config.ts` (Next only auto-loads the app dir's own .env); `transpilePackages` for the workspace TS packages + `serverExternalPackages: ["pg-boss", "postgres"]`.
6. **zod v4:** `z.toJSONSchema(schema, { target: "draft-2020-12" })` is native; `z.record(keyType, valueType)` needs both args. Anthropic structured outputs/tools need the `closeObjects` normalization (see `ai/tools.ts`).
7. **Drizzle wraps PG errors** as "Failed query: …" — never assert on Postgres error message text (the RLS test asserts on *outcome* instead).
8. **ToolDef contravariance:** the erased element type must be `ToolDef<any>`; `unknown` makes concrete tools unassignable to `ToolDef[]`.
9. **Claude API drift** (from the claude-api skill; re-check it before writing AI code): Opus 4.8 rejects `temperature/top_p/top_k` and `budget_tokens` (use `thinking: {type:"adaptive"}`); assistant prefill 400s; thinking blocks must be echoed back unchanged between tool rounds; `output_config.format` is the structured-output surface.
10. **RLS + superuser:** FORCE binds table owners but NOT superusers — any RLS claim must be verified through `ai_kernel_app`. `current_setting('app.tenant_id', true)` + `NULLIF(...,'')` yields NULL (→ zero rows) when the GUC is unset, which is the safe default.
11. Events + metering run **inside the tenant transaction**: a thrown event handler rolls back the triggering action; `ai_usage` rows vanish on rollback. Both deliberate for now.
12. Next dev auto-added `typescript` to `apps/demo` devDependencies (Next requirement); harmless.
13. **The shared remote Postgres intermittently stalls NEW connection handshakes** (observed 2026-07-14 ~03:00–03:15 local, self-cleared). Signature: the FIRST DB query of a test file (or a fresh pool) hangs until hook/test timeout while everything else passes; re-running the file alone passes. During the window: raw TCP connects fine (~0.2s), concurrent `psql` connects fine (~1.4s), but `postgres.js` gets `CONNECT_TIMEOUT`. Not a code bug — do NOT "fix" the kernel for it. Mitigations in place: root `pnpm test` serializes packages (`--concurrency=1`), vitest `hookTimeout: 30_000` everywhere. To diagnose a recurrence: (a) `psql -c "SELECT count(*) FROM pg_stat_activity"` + `SHOW max_connections` (rule out slot exhaustion), (b) time a few sequential `psql -c "SELECT 1"` connects, (c) a Node one-liner opening 6 concurrent `postgres.js` connections. If all pass, it was transient; re-run the gate. Baseline connect latency to this server is ~1.2–1.5s — pools amortize it, but expect slow first requests after boot.
14. **The shared dev Postgres is TAILNET-ONLY since 2026-08-21** (found 2026-09-10 by the Paiduay mint). The box that hosts it (`firstdb`, the james.in.th server) had ufw enabled on 2026-08-21, which closed public 5432 — the "consumer census" lesson in `92_DEPLOYMENT_PATTERN_GENERIC_GUIDE.md` §5.5. Its public IP still answers ICMP but 5432 times out; the tailnet address `100.103.47.82:5432` works. The root `.env` (`POSTGRES_HOST`, `APP_DATABASE_URL`) was repointed to the tailnet IP on 2026-09-10 (backup of the old file outside the repo). Consequence: local dev, seeds and the test gate need Tailscale up on the Mac; the production VPS uses its own host Postgres and is unaffected.

## 4. Module composition ("kernel v2") — ✅ BUILT & PROVEN 2026-07-12

> **STATUS UPDATE (2026-07-12, same day, later session):** everything below was
> implemented, tested (registry.test.ts, flow-b.test.ts) and verified live.
> The as-built record is `docs/foundation/02-kernel-spec.md` §9.18–9.20 — read
> that, not this section, for current truth. Deviations from the sketch below:
> (a) manifest stayed FLAT (no per-module sections — keys already namespace);
> (b) cross-module glue ships as **bridge modules** (`bookingInvoicingBridge`)
> rather than subscriptions inside core modules; (c) `KernelRuntime.registry`
> was added so module code can resolve refs/machines; (d) UI contributions are
> descriptors + data loaders (no React in modules). Module spec sheets for the
> rest of the catalog live in `docs/handoff/modules/`.

Original design direction (kept for rationale):

1. **Cross-module entity references:** soft refs as `{ type: string, id: uuid }` pairs stored in `record.data` (e.g. an invoice holds `source: { type: "booking", id }`), validated at write time against the registry's known entity types. No hard FKs between module tables — modules stay independently installable. The kernel gains a `resolveRef(ctx, ref)` helper.
2. **Event contract registry:** `ModuleDef` gains `emits: Array<{ type: string; payload: zodSchema }>`; `createKernel` validates that every `events:` subscription targets a declared event (kernel `record.*` events are pre-declared). Gives typed, discoverable cross-module wiring.
3. **Permission namespacing:** enforce at registration that a module's permissions all start with `<something>.` it owns (today it's convention: `booking.*`). Role templates (e.g. "front-desk", "manager") become manifest-level bundles of permissions across installed modules.
4. **UI contribution points:** `ModuleDef` gains `ui?: { nav?: NavItem[]; dashboardWidgets?: WidgetDescriptor[]; recordTabs?: Array<{ forEntityType: string; tab: TabDescriptor }> }` — e.g. sales-documents contributes an "Invoices" tab to booking detail pages. The presentation shell renders contributions; modules never import each other's UI.
5. **Module dependencies:** `ModuleDef.dependsOn?: string[]`; `createKernel` topo-sorts, errors on missing deps. The manifest becomes multi-module: `manifests: Record<moduleName, section>` (today's single-module manifest is the degenerate case; keep backward compat or migrate the two demo manifests).
6. **Add-a-module-later:** each module owns a `migrations/` dir; the kernel migration runner gains per-module tracking so a deployed client app can install a module post-hoc.
7. **Party stays kernel-owned.** Modules attach via `record_parties` roles, tags, and their own tables keyed by `party_id` (e.g. contacts-crm adds `crm_pipeline_states`). Never fork the customer.

**Composition is proven only when two modules run in ONE app** exercising a ref (invoice→booking), a cross-module event subscription, and a contributed UI tab. That app + test is the acceptance criterion for the whole design.

## 5. pgvector activation checklist (now unblocked; ~half a day)

1. Migration `0002`: `CREATE EXTENSION IF NOT EXISTS vector;` + `ALTER TABLE kernel.grounding_chunks ADD COLUMN embedding public.vector(1024);` (dimension per chosen model) + HNSW index (`USING hnsw (embedding public.vector_cosine_ops)`), re-grant to `ai_kernel_app`.
2. **Embeddings provider:** Anthropic has none. Options: Voyage AI (recommended; `voyage-3.5` family, needs `VOYAGE_API_KEY`) implemented as `ProviderAdapter.embed` on a small `VoyageAdapter`, or self-hosted later (D10 door). Wire through `KernelGateway.embed`.
3. Embed at grounding ingestion (seed) + a backfill script; store alongside content.
4. Implement `PgVectorRetriever.search` (cosine distance, tenant-scoped via RLS exactly like LexicalRetriever); make retriever selection config (`KERNEL_RETRIEVER=lexical|pgvector`), keep lexical as fallback.
5. When it matters: grounding beyond a few pages, and **cross-language queries** (Thai question over English grounding is where lexical fails — important for SEA).

## 6. Testing & verification conventions

- Unit tests colocated `*.test.ts` (pure kernel logic: rbac, status machine, bus, tool harness). DB integration tests run against the REAL remote DB with slug-namespaced fixtures + full cleanup in beforeAll/afterAll; `fileParallelism: false` (shared DB).
- **Fixture names come from `fixtureSlug()` (`@factory/core/testing`), never from a literal** (spec §9.39, O21). It yields `vitest-<prefix>-<name>` where `<prefix>` is 6 hex chars derived from the checkout root path — stable per checkout (so a crashed run's leftovers are cleaned by the next run of the SAME checkout) and distinct between checkouts (so parallel gates on the shared DB never delete each other's rows). Override with `VITEST_SLUG_PREFIX`. Applies to every colliding value: tenant slugs, party names, user emails, portal auth ids, conversation ids, temp dirs.
- AI flows are tested with `MockAdapter` (`@factory/core/testing`) — queue tool-calls + text per turn; asserts on the serialized system prompt/tools too. No network in tests.
- Live verification recipe (the one used originally): `curl -X POST localhost:3100/api/chat -d '{"conversationId":"x","message":"..."}'` → check reply; `psql` the `kernel.records/audit_events/ai_usage` tables; `pnpm --filter demo exec tsx scripts/confirm-latest.ts` to confirm; insert into `kernel_jobs.job` to force a reminder immediately.
- Full gate: `pnpm typecheck && pnpm test` at root (turbo across packages).

## 7. Deferred mechanical backlog (work orders for Opus/Sonnet; ordered)

Each should follow: implement → verify live via §6 recipe → add tests where meaningful → record decisions in spec §9 → commit.

1. ~~**Chat hardening**~~ — ✅ **DONE 2026-07-14 (spec §9.27).** In-memory `TokenBucket` (kernel) + two-bucket policy (conversation *and* IP) at `/api/chat`; per-tenant daily token budget checked in `KernelGateway` before the provider call (in-band friendly denial for `chat()`, `BudgetExceededError` for `object()`/`embed()`); `maxTokens`/`maxToolRounds` clamped to configured caps. Env keys in `.env.example`. **Multi-node caveat:** the buckets are per process — a second web replica needs the shared-store swap described in §9.27.
2. **Deploy:** Dockerfile (standalone Next build + worker entrypoint), Coolify on the VPS, healthcheck route, `pnpm db:migrate` release step. Two processes: web + worker (`scripts/worker.ts`).
3. **Conversations inbox + human takeover:** admin page listing `channel_messages` conversations; per-conversation `bot_paused` flag (new kernel table or tenant-scoped KV); operator reply = outbound message via adapter; binding checks the flag before calling the gateway.
4. **Operator notifications:** module subscribes to `record.booking.created` → email adapter (SMTP) or LINE Notify.
5. **LINE channel adapter:** implements `ChannelAdapter` (webhook route → `handleInbound`; `send` via LINE Messaging API push). Conversation id = LINE userId. Reuses everything else unchanged.
6. **Embeddable widget:** `/widget.js` + iframe route with theme tokens from manifest branding; postMessage sizing.
7. **pgvector** per §5. 8. **Langfuse Tracer impl** (one class). 9. **i18n pass** on presentation shell. 10. **Shell per-transition RBAC** (found 2026-07-14, spec §9.25): `transitionAction` never checks permissions — any logged-in operator can advance any record. Add one check in the shell (convention: `${entityType}.transition` or per-target like `lead.convert`), applied uniformly; role bundles already carry the permission strings.

**Internet-business expansion track (2026-07-17, spec §9.28 — separate queue, ordered):**

11. ~~**Kernel sockets (Phase 2)**~~ — ✅ **DONE 2026-07-17** (spec §9.29/§9.30 BUILT; migration 0002 applied; 10 tests).
12. **documents-files build** per sheet 11 + §9.31 amendment (S3-compatible + presigned uploads). Needs James: MinIO-via-Docker vs a real bucket for dev.
13. ~~**payments build**~~ — ✅ **DONE 2026-07-17** (spec §9.32 BUILT; `packages/modules/payments`, 6 tests; deviations in §9.32).
14. **marketplace-listings** per sheet 16 (needs 11). 15. **marketplace-orders + settlementBridge** per sheet 17 (needs 12–14).
16. **Cameo mint (Phase 4):** `factory:create-app` → `apps/cameo-demo`; app-layer public pages (home/browse/profile), portal pages (talent requests), guest order page with playback; seed talents; live-verify the full arc; add the recipe to `40-verification-playbook.md`. Composer note: public/portal pages are the FIRST app surfaces outside the admin+chat template — expect template drift work in `packages/factory`.

17. **Composer template drift — a mint no longer builds (found 2026-08-19 by O22, spec §9.46; tracker O27).** `pnpm factory:create-app` produces an app that fails `next build` for any module set other than the ones already minted (the drift item 16 predicted, now real). Two independent causes: **(a) page pruning** — `apps/demo` has grown module-specific routes (`app/admin/catalog/*`, `app/admin/customers/*`, `app/api/files/*`) that are copied verbatim while the corresponding packages are dropped from `dependencies` for a partial `--modules` set, so the imports dangle and the mint does not compile; the composer needs to know which app surfaces belong to which module (registry metadata, e.g. `appRoutes: string[]`) and prune them. **(b) factory modules** — `registry.ts` lists `marketplaceListingsModule`/`marketplaceOrdersModule` as plain exports when they are factories (`marketplaceListingsModule({ resolveSampleUrl })`, `marketplaceOrdersModule()`), and codegen emits `invoicePaymentBridge({ })` for a bridge that takes no arguments; the registry needs a call-form/default-options field and `create-app.ts` must emit accordingly. Verify by minting BOTH a partial set and the full set and running `pnpm --filter <mint> build` (which now type-checks, per §9.46). Needs a design call, not a stamp.

## 8. Factory composer (codegen) — ✅ BUILT 2026-07-12 (spec §9.21)

`pnpm factory:create-app -- --name <client> --brand "<Name>" --tagline "…" --modules scheduled-interactions,sales-documents --port 3200` generates `apps/<client>` from the demo template + `packages/modules/registry.ts` metadata (bridges auto-included when all their `requires` are selected; hook stubs emitted to `lib/hooks.ts`). Proven with `apps/barber-demo` (typechecked untouched, booted, chat verified on its own tenant). Theme tokens also done (spec §9.20): `branding.theme` → CSS vars → Tailwind utilities; chat is the polished reference surface. The future console GUI calls exactly this generator.
