# Kernel Design Spec (Living)

**Doc-set version:** 0.1 · **Date:** 2026-07-11 · **Status:** Living
**Audience:** the implementer — a future AI session, or an experienced engineer.
**Read `00-overview` first** for the mental model, scope boundary, principles, and Decision Register (referenced here as *→ Dx*).

---

## §0 Status & how to evolve this document

**This is a living foundation, not a frozen blueprint.** It is deliberately correct-and-minimal, not complete. It exists to be *built on, argued with, and extended*.

**Status legend** (applied per component and per tech choice):
- **Decided** — settled with the operator; change only with a new decision recorded in `00` §6.
- **Proposed** — a strong default the author recommends; the implementer may adopt, or propose an alternative *before* committing.
- **Open** — genuinely undecided; needs a choice during the build, ideally surfaced to the operator.

**Standing invitation to the implementer.** You are expected to:
1. **Propose** better options where you see them — especially on anything tagged *Proposed* or *Open*. Bring the trade-off, don't silently swap.
2. **Extend** §9 (Open Questions & Proposals) as you discover new questions.
3. **Surface, don't assume.** Where a choice affects cost, lock-in, data residency, or the operator's vibe-coding workflow, raise it rather than deciding unilaterally.
4. **Keep the layer discipline** (`00` §2): the kernel must contain nothing about any feature archetype or vertical.

**Changelog**
- `0.2` (2026-07-11) — thin-slice implementation landed (walking skeleton): monorepo, `@factory/core` with all seven components, demo module `scheduled-interactions`, Next.js demo app, two vertical manifests. Reference Flow A validated end-to-end against live Claude. Build-start decisions recorded in §9 (items 10–17).
- `0.1` (2026-07-11) — initial foundation: architecture, seven kernel components, tech stack, reference flows, data-model sketch, open questions.

---

## §1 Purpose & scope

**Purpose.** Define the **kernel** — the archetype-agnostic platform substrate on which all future feature modules and verticals are built (`00` §2, layer 3).

**In scope:** the seven kernel components (§4), their contracts, the module/vertical extension model, the tech stack, and a data-model sketch.

**Hard non-goals** (full list §8): no feature modules (no front-desk, follow-up, quoting, reporting…), no chosen vertical, no runtime metadata engine (→ **D8**), no committed billing provider (→ **D12**).

**Fundamental character:** **code-first, SMB-weight, provider-abstracted.** The kernel provides *services + typed contracts*; a module's entities and logic are ordinary typed code the operator vibe-codes — not runtime-configured data. → **D8**.

---

## §2 Principles → technical implications

The five principles (`00` §4) translate to concrete engineering rules:

| Principle | Technical implication |
|-----------|-----------------------|
| Codegen-optimized | Mainstream, boringly-documented stack; conventional file layout; typed everything; avoid exotic abstractions an LLM can't reliably generate against. → **D7** |
| Provider-abstracted AI | One `LLMGateway` interface; providers are adapters; model choice is config. Self-hosting is an adapter, not a rewrite. → **D9**, **D10** |
| Thin verticals on a shared core | A published, versioned `core` package; modules and verticals depend on it; no vertical logic in the kernel. → **D6** |
| Portable deploy | Container-first; no reliance on a single PaaS's proprietary primitives; Postgres + filesystem/object-store are the only hard infra deps. |
| SMB-weight | Prefer one Postgres over a service mesh; a Postgres-backed job queue over a separate broker; opinionated defaults over configuration surface. → **D3**, **D8** |

---

## §3 Architecture overview

**Repository shape (→ D6): a monorepo with a versioned shared `core`.**

```
/apps
  /<vertical-app>        # thin: imports core + one or more modules + a vertical manifest
/packages
  /core                  # THE KERNEL — the seven components of §4
  /modules
    /<module-name>       # layer-4 capability sets (FUTURE — not in this session)
  /config                # shared tsconfig, eslint, etc.
```

**Layering (strict, one-directional dependency):**

```
kernel (core)  ←  module (via Module SDK)  ←  vertical (via manifest)  ←  deployed app
```

- The **kernel** knows nothing above it.
- A **module** registers into the kernel through the **Module SDK** (§4.6) — declaring entities, permissions, UI, AI tools, jobs, channels.
- A **vertical** specializes a module through a **manifest** (§4.6) — labels, fields, workflow, grounding content, prompts, branding, language.
- A **deployed app** composes core + module(s) + manifest, and is packaged as a container (§5).

**The code-first module model (→ D8).** A module defines its entities as typed code (schema + types), not as runtime metadata. The kernel supplies the *primitives and services* those entities are built from (§4.2) and the *contract* by which they register (§4.6). "Configurability" comes from (a) vibe-coding a new typed module quickly and (b) a **thin** per-tenant config/custom-field facility added only when a real need appears (YAGNI) — never a general runtime entity engine.

---

## §4 Kernel components

Seven components. Each lists **responsibility · contract · depends on · in/out**. The three load-bearing ones (§4.2 Data core, §4.3 AI substrate, §4.6 Extension contract) carry interface **sketches** — illustrative shapes to refine during implementation, *not frozen signatures*.

### 4.1 Tenancy & identity — *Decided (shape); Open (isolation strategy)*
- **Responsibility:** multi-tenant isolation; users, sessions, roles, permissions (RBAC). A module declares the permissions it needs; the kernel enforces them.
- **Contract:** every persisted row is tenant-scoped; every request resolves to a `{ tenantId, userId, roles }` context that guards data access and AI/tool calls.
- **Depends on:** data core (4.2), auth library (§5).
- **In:** tenant model, user/role/permission model, session handling, an authorization guard. **Out:** the billing/plan logic behind entitlements (billing seam, 4.7 — seam only).
- **Open:** row-level-security vs schema-per-tenant — see §9.

### 4.2 Data core — *Decided (primitives); Proposed (persistence details)* — **load-bearing**
- **Responsibility:** typed persistence over Postgres, plus a small library of reusable **business primitives** every SMB app rebuilds anyway, so modules compose rather than reinvent.
- **Primitives:** **Party** (person/org), **Record/Document** (generalized transactional object), **Money**, **Status machine** (declared lifecycle), **Audit/event log**, **Attachment**, **custom-fields** (the thin per-tenant extension point).
- **Depends on:** Postgres (+ pgvector), the ORM (§5).
- **In:** primitive schemas + typed helpers, tenant-scoped repository access, migrations, the audit trail. **Out:** any specific business object (that's a module's job).

```ts
// SKETCH — refine in implementation. Illustrates shape, not final API.

type ID = string; // uuid

interface TenantScoped { tenantId: ID; }

interface Party extends TenantScoped {
  id: ID;
  kind: "person" | "org";
  name: string;
  contacts: Array<{ channel: "email" | "phone" | "whatsapp" | "line"; value: string }>;
  tags: string[];
  custom: Record<string, unknown>;   // thin custom-fields; validated by a module/vertical schema
}

// A declared lifecycle a module attaches to any Record type.
interface StatusMachine<S extends string> {
  initial: S;
  transitions: Partial<Record<S, S[]>>;                 // state -> allowed next states
  onEnter?: Partial<Record<S, (ctx: ActionContext) => Promise<void>>>; // emits events (4.4)
}

// Generalized transactional object; a module narrows `type` and `data`.
interface RecordDoc<TState extends string = string, TData = unknown> extends TenantScoped {
  id: ID;
  type: string;                 // e.g. module-defined "job" | "invoice" | "ticket"
  state: TState;
  parties: Array<{ role: string; partyId: ID }>;   // e.g. customer, assignee
  money?: Money;
  attachments: Attachment[];
  custom: Record<string, unknown>;
  createdAt: string; updatedAt: string;
}

interface Money { amount: number; currency: string; }  // minor-unit integer recommended at impl time
```

### 4.3 AI substrate — *Decided (gateway shape); Proposed (libraries)* — **load-bearing**
- **Responsibility:** the provider-abstracted **AI gateway** plus reusable AI-over-your-data plumbing: chat/completion, structured output, embeddings + retrieval, and a **thin tool/agent harness** that lets the AI call kernel/module actions and use tenant context. This is what sits *beneath* any future "front-desk," "copilot," or "assistant" feature.
- **Depends on:** data core (retrieval targets + tool actions), tenancy (scoping every call), job queue (async/batch).
- **In:** the gateway interface + provider adapters (Claude default; Bedrock/Vertex; self-hosted vLLM/Ollama — → **D9**, **D10**), retrieval over tenant data (pgvector), a tool-registration + execution harness, per-tenant usage metering + tracing. **Out:** any feature-specific prompt or agent (that's a module).

```ts
// SKETCH — refine in implementation.

interface LLMGateway {
  chat(req: ChatRequest, ctx: AIContext): Promise<ChatResult>;
  object<T>(req: ObjectRequest<T>, ctx: AIContext): Promise<T>;   // structured output, schema-validated
  embed(texts: string[], ctx: AIContext): Promise<number[][]>;
}

interface AIContext {
  tenantId: ID; userId?: ID;
  model?: ModelRef;             // optional override; else the configured default (Claude)
  tools?: ToolHandle[];         // subset the caller exposes for this turn
  budget?: { maxTokens?: number; maxCostUsd?: number };
}

type ModelRef =
  | { provider: "anthropic"; model: string }     // default tier, e.g. latest Claude
  | { provider: "bedrock" | "vertex"; model: string }
  | { provider: "selfhosted"; endpoint: string; model: string };  // → D10 door

// The thin harness: a tool is a typed, permission-checked action over kernel/module data.
interface Tool<Args, Result> {
  name: string;
  description: string;
  input: JsonSchema;                       // validated before execution
  requires?: string[];                     // permissions checked against AIContext
  run(args: Args, ctx: ActionContext): Promise<Result>;
}

// Retrieval grounds AI in a tenant's own records; always tenant-scoped.
interface Retriever {
  search(query: string, ctx: AIContext, opts?: { k?: number; filter?: Record<string, unknown> }): Promise<Chunk[]>;
}
```

### 4.4 Async & integration — *Decided (shape); Proposed (libraries)*
- **Responsibility:** background jobs + scheduler (beneath reminders/follow-ups), an **event bus** (the extensibility spine), and a **channel/webhook adapter framework** (beneath WhatsApp/LINE/email).
- **Depends on:** Postgres (queue + event store), data core.
- **In:** a Postgres-backed job queue + cron scheduler, an in-process typed event bus (publish on record/state changes; modules subscribe), a channel-adapter interface with pluggable providers, inbound/outbound webhooks. **Out:** specific channel *content* or automations (module territory).

```ts
// SKETCH
interface EventBus { publish(e: DomainEvent): Promise<void>; on(type: string, h: EventHandler): void; }
interface Jobs { enqueue(name: string, payload: unknown, opts?: { runAt?: string; tenantId?: ID }): Promise<ID>;
                 define(name: string, handler: JobHandler): void; }
interface ChannelAdapter { channel: "whatsapp" | "line" | "email" | "webhook";
                           send(to: string, msg: OutboundMessage, ctx: TenantScoped): Promise<void>;
                           onInbound(h: (m: InboundMessage) => Promise<void>): void; }
```

### 4.5 Presentation shell — *Proposed*
- **Responsibility:** an opinionated admin/back-office UI: auth screens, tenant switcher, navigation, and CRUD scaffolding generated over a module's entities, with theming + i18n baked in. So a new module gets a usable operator UI cheaply.
- **Depends on:** Next.js (§5), tenancy, data core, Module SDK (for what to render).
- **In:** the app shell, auth-gated routing, a data-table/detail/form scaffold driven by a module's entity descriptors, theme tokens, an i18n provider. **Out:** any vertical-specific screen or branding beyond theme tokens + manifest (vertical territory).

### 4.6 Extension contract (Module SDK + Vertical manifest) — *Decided (that it exists); Proposed (exact surface)* — **load-bearing; the reusable IP**
- **Responsibility:** the single typed seam by which a **module** plugs into the kernel, and by which a **vertical** specializes a module. This contract is what makes "new vertical in hours" literally true, and is the most important thing to get clean.
- **Depends on:** all other components (it's how they're extended).
- **In:** `defineModule` (register entities, permissions, UI descriptors, AI tools, jobs, channel handlers, event subscriptions) and the `VerticalManifest` schema (labels, custom fields, workflow overrides, grounding content, prompt overrides, branding, locale). **Out:** nothing feature-specific ships here — only the contract.

```ts
// SKETCH — the shape of the reusable IP. Refine deliberately.

interface ModuleDef {
  name: string;
  entities: EntityDescriptor[];          // built on data-core primitives; drive persistence + UI (4.5)
  permissions: string[];                 // enforced by 4.1
  aiTools?: Tool<any, any>[];            // exposed to the AI harness (4.3)
  jobs?: Array<{ name: string; handler: JobHandler }>; // registered with 4.4
  events?: Array<{ type: string; handler: EventHandler }>;
  channels?: ChannelBinding[];           // how this module reacts to inbound messages
}
declare function defineModule(def: ModuleDef): Module;

interface VerticalManifest {
  module: string;                        // which module this specializes
  locale: string;                        // e.g. "th-TH", "id-ID"
  labels: Record<string, string>;        // rename entities/fields per industry
  customFields?: Record<string, JsonSchema>;
  workflow?: Record<string, StatusMachine<string>>;  // override lifecycles
  grounding?: GroundingSource[];         // FAQ/policy/catalog the AI is grounded in (4.3 retrieval)
  prompts?: Record<string, string>;      // per-vertical prompt overrides
  branding?: { name: string; theme?: ThemeTokens; logo?: string };
}
```

### 4.7 Cross-cutting — *Proposed / Deferred*
- **Billing seam** (interface only; no provider — → **D12**), **secrets/config**, **observability** (LLM tracing via Langfuse + error tracking), **migrations**, **container packaging** (§5). Each is a thin, swappable concern the kernel exposes but does not over-specify now.

---

## §5 Tech stack & rationale

Each choice is tagged; *Proposed* items are the author's default, open to a better alternative *before* the implementer commits.

| Concern | Choice | Status | Why | Rejected alternative |
|---------|--------|--------|-----|----------------------|
| Language | TypeScript | Decided | One language full-stack; largest codegen corpus. → **D7** | Python-centric (2nd language to run solo) |
| Framework | Next.js (App Router) | Decided | Operator familiarity; server + client in one; huge LLM training data. → **D7** | Separate API + SPA (more surface to maintain) |
| Database | Postgres + pgvector | Decided | One store for relational + JSONB + vectors → SMB-light. | Separate vector DB (extra infra) |
| ORM | Drizzle | Proposed | SQL-first, excellent TS inference, light, codegen-friendly. | Prisma (heavier runtime; still viable) |
| Auth | Better Auth | Proposed | Self-hostable, TS-native, no per-MAU cost, data-residency friendly. | Clerk/Auth0 (managed but cost + residency) |
| AI in-app layer | Vercel AI SDK | Proposed | Provider-agnostic, streaming, tool-calling, strong DX; fits the gateway. | Direct SDKs (more glue) |
| Model routing | Router seam (LiteLLM / OpenRouter / self-host) behind `LLMGateway` | Proposed | Swap/observe providers by config; the **D10** door. | Hard-coded provider (lock-in) |
| Default model | Latest Claude (Opus 4.8 / Sonnet 5 / Haiku 4.5 by tier) | Proposed | Strongest general models in 2026; behind the gateway so swappable. | Single fixed model |
| Jobs/queue | Postgres-backed (pg-boss / Graphile Worker) | Proposed | No extra broker; SMB-light. | Redis/BullMQ (extra infra) |
| Validation | Zod | Proposed | Pairs with AI structured output + manifest validation. | ad-hoc validation |
| LLM observability | Langfuse (self-hosted) | Proposed | Tracing + cost/usage; data-residency friendly. | SaaS-only tracing |
| Error tracking | Sentry | Proposed | Standard, low-effort. | roll-your-own |
| Deploy | Docker + Coolify on the VPS (primary); managed cloud optional per client | Proposed | Portable, flat-cost, data-residency; managed available when speed matters. → Principle 4 | Vercel-only (lock-in + residency limits) |
| Monorepo | pnpm workspaces + Turborepo | Proposed | Standard, fast, codegen-friendly. | Nx (heavier) |

---

## §6 Reference validation flows

Two **generic, non-committal** flows, used only to prove the kernel's contracts hold. They commit us to *no* vertical or archetype (→ **D11**); they are illustrative.

**Flow A — a scheduled-interaction lifecycle** (could be a booking, a job, a consultation — deliberately unspecified):
1. A **Party** (customer) contacts the business over a **channel adapter** (4.4).
2. The **AI harness** (4.3) answers using **retrieval** over the tenant's grounding content, then calls a `createRecord` **tool** to open a **Record** with a **Status machine** `requested → confirmed → done` (4.2).
3. Assignment adds a staff **Party**; a **job** (4.4) schedules a reminder; the reminder sends via a channel adapter.
4. Each transition writes to the **audit log**; **RBAC** (4.1) gates who can advance state; the operator sees it all in the **presentation shell** (4.5).
- **Exercises:** every component. ✅ Contracts hold.

**Flow B — a document-approval + reporting cycle** (could be quotes, invoices, tickets — unspecified):
1. A **Record/Document** with **Money** and **Attachments** moves `draft → submitted → approved|rejected` (4.2).
2. On `approved`, the **event bus** (4.4) fires; a subscribed **job** aggregates records.
3. An **AI copilot** answers a natural-language question over the tenant's records via **retrieval + a query tool** (4.3), tenant-scoped by **AIContext**.
- **Exercises:** data core (Record, Money, Attachment, custom-fields), event bus, jobs, AI-over-data, tenancy. ✅ Contracts hold.

*If a future abstraction cannot be traced through a plausible generic flow, treat that as a signal it may be speculative — see §9.*

---

## §7 Kernel data-model sketch

Tables the **kernel itself** owns (a module adds its own, built on these). Illustrative, not final DDL.

- `tenants` — id, name, config, created_at
- `users`, `roles`, `user_roles`, `permissions` — identity + RBAC (4.1)
- `parties` — 4.2 primitive (tenant-scoped)
- `records` — 4.2 generalized transactional object (tenant-scoped; `type`, `state`, JSONB `data`/`custom`)
- `record_parties` — role-tagged links (customer, assignee…)
- `attachments` — 4.2
- `audit_events` — append-only audit + domain-event log (4.2 / 4.4)
- `embeddings` — pgvector chunks for retrieval (4.3), tenant-scoped
- `ai_usage` — per-tenant token/cost metering (4.3)
- `jobs` — queue + schedule (4.4; provided by the queue lib's schema)
- `custom_field_defs` — thin per-tenant/vertical field definitions (4.2)

Cross-cutting: every tenant-scoped table carries `tenant_id`; the isolation mechanism (RLS vs schema-per-tenant) is **Open** (§9).

---

## §8 Non-goals & deferred

Explicitly **out** of the kernel / this session:

- **Feature modules** — front-desk, follow-up, quoting, scheduling UX, reporting dashboards. (Layer 4, future.) → **D11**
- **Any specific vertical.** → **D13**
- **A runtime metadata / low-code engine.** Rejected outright. → **D8**
- **A committed billing/payments provider.** Seam only. → **D12**
- **Self-hosted model *serving* infrastructure.** Adapter seam only; not stood up now. → **D10**
- **Marketing site, onboarding flows, per-client customization tooling.** Later.

---

## §9 Open Questions & Proposals

The living heart of this document. Expected to grow. Each item: what's open, and the current lean.

1. **Multi-tenancy isolation — Open.** Row-level-security (one schema, `tenant_id` + Postgres RLS) vs schema-per-tenant vs database-per-tenant. *Lean:* RLS for simplicity/SMB-weight; revisit schema-per-tenant for a client with hard data-residency/isolation demands. Decide at build start.
2. **ORM final pick — Proposed (Drizzle).** Confirm against Prisma once real entities exist; whichever gives cleaner codegen ergonomics for the operator wins.
3. **Module UI generation depth — Open.** How much operator UI is auto-scaffolded from entity descriptors (4.5) vs hand-written per module. *Lean:* scaffold CRUD + tables/forms; hand-write the rest.
4. **Tool/agent harness — how thin?** — *Proposed thin.* Start with single-step tool-calling + retrieval; add multi-step agent loops only when a module demands it. Avoid adopting a heavy agent framework prematurely.
5. **Custom-fields boundary — Open.** How far the thin per-tenant custom-field facility goes before it risks becoming the runtime engine we rejected (**D8**). *Lean:* typed JSONB + a per-vertical Zod schema; no dynamic relations, no runtime entity creation.
6. **Manifest vs code split — Open.** Exactly which specialization lives in the declarative `VerticalManifest` vs in vertical code. *Lean:* data/labels/grounding/prompts declarative; behavior in code.
7. **Money representation — Proposed.** Minor-unit integers + currency; a decimal library only if a vertical needs complex tax/rounding.
8. **Retrieval indexing strategy — Open.** What tenant data is embedded, when, and how it's kept fresh (on write vs batch job). Decide with the first module that uses retrieval.
9. **Model default + fallback policy — Proposed.** Default to a latest-Claude tier; define a fallback/routing policy (cost/latency/availability) at implementation.

10. **Multi-tenancy isolation — DECIDED (build 0.2): RLS.** One `kernel` Postgres schema (the host DB is shared with other projects — schema is namespacing, not the tenancy mechanism), `tenant_id` on every business table, policies `ENABLE + FORCE`d. Because the admin connection is a superuser (bypasses RLS), the migration provisions a restricted `ai_kernel_app` role that all runtime access goes through; `withTenant(tenantId, fn)` sets the transaction-local `app.tenant_id` GUC the policies check. Verified: cross-tenant reads return nothing, cross-tenant writes are rejected. *Refinement:* identity tables (`tenants`, `users`, `roles`, `user_roles`) and Better Auth tables are deliberately outside RLS — they are read while establishing tenant context; business-data tables are the isolation surface.
11. **ORM — DECIDED (build 0.2): Drizzle.** Codegen ergonomics were good in practice. DDL source of truth is hand-written SQL in `packages/core/migrations/` (RLS, roles, and grants are beyond ORM-migration territory); the Drizzle schema mirrors it for typed queries. Modules import query helpers (`sql`, `eq`, …) re-exported from `@factory/core` so the workspace shares one drizzle instance.
12. **Jobs — DECIDED (build 0.2): pg-boss** in its own `kernel_jobs` schema. It connects as the admin role (trusted server-side infrastructure); tenant isolation for job *effects* comes from the RLS-scoped ActionContext each handler receives. Verified: delayed reminder scheduled by a status-machine `onEnter` hook, delivered through the channel adapter.
13. **AI layer — DECIDED (build 0.2), deviation from the 0.1 proposal:** the Vercel AI SDK was dropped; the Anthropic adapter uses the official `@anthropic-ai/sdk` directly. Rationale: the kernel's own `LLMGateway`/`ProviderAdapter` seam *is* the provider abstraction (→ D9), so a second abstraction layer added surface without adding optionality. Each future provider (Bedrock/Vertex/self-hosted) implements `ProviderAdapter` — owning its native message format and tool loop — and plugs into the same gateway. A `MockAdapter` proves the seam and powers network-free integration tests. Default model: `claude-opus-4-8`, adaptive thinking, configured via `KERNEL_MODEL_DEFAULT`.
14. **Retrieval — build 0.2 refinement of §9.8:** the host Postgres lacks the pgvector extension, so slice one ships a `LexicalRetriever` (pg_trgm similarity) behind the `Retriever` interface; `PgVectorRetriever` is a typed `NotImplemented` seam. Anthropic has no embeddings endpoint — an embeddings provider (e.g. Voyage or self-hosted) becomes a `ProviderAdapter.embed` implementation when semantic retrieval is needed. Grounding sets are small; lexical ranking was adequate in live testing.
15. **Observability — build 0.2:** Langfuse/Sentry deferred (they don't serve the demo bar); every AI call and job logs through a thin `Tracer` interface (console impl), and per-tenant token usage is metered to `ai_usage` per call with a `purpose` label. Langfuse later = one `Tracer` implementation, zero call-site changes.
16. **Auth — DECIDED (build 0.2): Better Auth** over kernel-owned `ba_*` tables (global, outside RLS). Operator identity maps `ba_users.id → kernel.users.auth_user_id`, scoped per tenant so one auth identity can operate several deployed verticals.
17. **Conversation-scoped AI tools — build 0.2 pattern worth keeping:** module tools that act on behalf of a customer are built per inbound message with the conversation handle bound by closure — the model never supplies (and cannot spoof) the customer's identity. Permission checks (`requires`) run against the channel binding's granted context; a denied tool returns an `is_error` tool result rather than crashing the conversation.

18. **Module composition ("kernel v2") — DECIDED & PROVEN (build 0.3).** The contracts from `handoff/20` §4, as implemented:
    - **Soft refs:** `EntityRef { type, id }` in `record.data`; `entityRef()/resolveRef()/assertKnownRefType()` helpers validate against the composed app's registry. No FKs between module concerns — modules stay independently installable.
    - **Runtime registry:** `KernelRuntime.registry` (has/get/types/machineFor with manifest overrides applied) so module code can work with other modules' entities without importing them. This is the seam refs, bridges, and generic UI all hang off.
    - **Event contracts:** kernel pre-declares `record.<type>.created|transitioned` per entity; modules declare additional events via `ModuleDef.emits`. `createKernel` rejects subscriptions no installed module declares — wiring typos and absent-module subscriptions fail at boot. Payload schemas are documentation/codegen input, not runtime-enforced (enforcement would demand zod schemas for RecordRow-carrying kernel events; cost > benefit now).
    - **Permission namespacing:** a module owns namespaces = its entity types + its name + explicit `permissionNamespaces`; every declared permission must live in an owned namespace and no two modules may claim one. Role templates are manifest-level `roles` bundles, created at seed.
    - **UI contribution points:** `ModuleDef.ui` = nav items, dashboard widgets, and record tabs — *descriptors with data loaders*, never React (same philosophy as `listColumns`; the shell renders generically, codegen can emit bespoke React later). Tab/widget hrefs may use the presentation shell's route convention (`/admin/<entity>/<id>`), which is part of the shell contract.
    - **deps + topo-sort:** `ModuleDef.dependsOn`; `createKernel` registers deps-first, errors on missing deps/cycles, and enforces manifest⇆composition set equality (drift fails loudly).
    - **Multi-module manifest:** `modules: string[]` (legacy `module` still accepted). Labels/prompts/workflow keys stay FLAT — they're already namespaced by entity type / prompt id; per-module nesting (the `Record<module, section>` idea) was rejected as structure without information.
    - **Per-module migrations:** the runner also applies `packages/modules/<name>/migrations/*.sql` (tracked as `<name>/<file>`) + `MODULE_MIGRATION_DIRS` for apps outside the monorepo. No module ships DDL yet — records/parties are generic — so this is implemented but not yet exercised in anger.
    - Acceptance proven live AND in `flow-b.test.ts`: scheduling + sales-documents + bridge in one app; booking→done drafts an invoice holding a ref, priced by an app-supplied hook; the bridge's contributed "Invoices" tab renders on the booking detail page.
19. **Bridge modules — build 0.3 pattern (the answer to "may modules know each other?").** Core modules never reference another module's entities. Cross-module glue (event subscriptions on another module's records, contributed tabs on its pages) ships as a small separate `ModuleDef` that declares `dependsOn` both sides — e.g. `bookingInvoicingBridge` in the sales-documents package. Apps install the bridge only when composing both. Vertical-specific glue parameters (like pricing) are code hooks the app passes to the bridge factory — pricing stayed in the app layer, deliberately NOT in the manifest, to avoid runtime-config creep (D8); a future catalog module will own it.
20. **Sales-documents v0 scope — build 0.3.** Quote (`draft→sent→accepted|declined|cancelled`) and invoice (`draft→issued→paid|void`) as plain kernel records (no module tables); line items + soft `source` ref in `data`; totals in record money. Deliberately deferred to the module spec sheet: invoice numbering sequences, PDF rendering, send-to-customer, payments (module #5), overdue job.

21. **Factory composer, first form — BUILT (build 0.3): `pnpm factory:create-app`.** Codegen per D8/handoff-10 §2: `packages/factory` copies `apps/demo` as the template and GENERATES the composition points — package.json (name/port/filtered module deps), next.config transpile list, `lib/manifest.ts` scaffold (modules list incl. auto-included bridges, per-entity label TODOs, theme/grounding TODOs, role bundles), `lib/kernel.ts` (module + bridge imports), `lib/hooks.ts` (bridge code-hook stubs), seed/tenant slug defaults. Input metadata = `packages/modules/registry.ts` (static; bridges auto-selected when all `requires` are present; the future console GUI calls this same generator). Proven: `apps/barber-demo` generated with both modules + bridge, typechecked untouched, booted on :3200, chat verified answering from its own seeded tenant. Generated apps are ordinary committed codebases (the product IS the codebase); barber-demo is kept as the living output example and can be regenerated/deleted freely. NOT in v1: regenerating over an existing app (no upgrade/diff), interactive mode, vertical content packs.
22. **Known kernel contract gaps — registry.** Writing the 13 module spec sheets surfaced a consolidated list of small kernel extensions (updateRecordData, channel-binding tool contribution, UI contribution limits, module drizzle-table exports, migrate.ts grant ordering, …) — maintained in `docs/handoff/modules/00-authoring-guide.md` §"Known contract gaps". Each is to be implemented only when a sheet's implementation pulls it, then recorded here. One was fixed immediately: duplicate channel bindings now fail at boot instead of silently overwriting.

23. **Kernel v2.1 — contract gaps pre-closed (build 0.4, 2026-07-14).** With frontier-model access unexpectedly extended, the §9.22 rule ("fix a gap only when a module pulls it") was deliberately front-run for every gap that is a *kernel contract change*, so later module implementations (by weaker models) never have to touch the kernel. Closed:
    - **`updateRecordData(ctx, id, patch)`** (gap 1): shallow-merge patch into `record.data`, `undefined` deletes a key, audits `record.data_updated` (payload = patched keys, not values) inside ctx.tx. Deliberately NO bus event and no dataSchema validation (consistent with createRecord; revisit only via a new §9 entry).
    - **Conversation-tool contribution seam** (gap 2): `ModuleDef.conversationTools?: (conversation: ConversationHandle) => ToolDef[]` → merged via `runtime.conversationTools(conversation)`; a channel binding appends these to its own tools. `handleInbound` grants = `binding.permissions ∪ requires(contributed tools)` — declaring a tool in `conversationTools` IS the grant decision (they are boot-validated module code); binding-OWNED tools stay gated by `binding.permissions` alone (the flow-a denial semantics are unchanged). Duplicate contributed names throw at merge; `createToolExecutor` also rejects duplicate names outright (silent shadowing = hijack hazard). Middleware contribution (messaging STOP handler) still deferred.
    - **`money` listColumns builtin** (gap 4): shell renders the record's Money via `formatMoney`; sales-documents lists now show a Total column instead of needing denormalized copies.
    - **Async `priceFor`** (gap 5): `BookingInvoicingOptions.priceFor` widened to `(booking, ctx) => Money | undefined | Promise<…>` — catalog-backed pricing needs ctx; sync hooks stay assignable.
    - **Module-owned drizzle tables** (gap 6): `@factory/core` re-exports the pg-core builders (`pgTable, text, uuid, boolean, bigint, integer, numeric, jsonb, timestamp, index, uniqueIndex, primaryKey`); modules define tables against `schema.kernel` (the kernel pgSchema) — still never importing drizzle-orm (rule 6).
    - **Grant ordering** (gap 7): `migrate.ts` now provisions the app role BEFORE module migrations; the blanket `GRANT ON ALL TABLES` runs ONCE (tracked as pseudo-migration `_app-role/baseline-grants`); new tables get DML via `ALTER DEFAULT PRIVILEGES` at CREATE time, so a module migration's REVOKE (append-only ledgers) is never silently undone by a re-run. Note: default privileges are per-creating-role — migrations must keep running as the same admin connection; module migrations should still carry explicit GRANTs (catalog's does).
    - **`lineItemSchema.sku`** (gap 8): optional, additive.
    - Left open deliberately: gap 3 (UI contribution limits — party surface waits for contacts-crm; module-table scaffold waits for a second module-table module), gap 9 (EntityRef for module tables — no puller), gap 10 (`party.*` namespace — contacts-crm), gap 11 (storage keys — documents-files).
    - **Test-gate hardening** (same commit): root `pnpm test` runs turbo with `--concurrency=1` and all vitest configs set `hookTimeout: 30_000` — the shared remote Postgres intermittently stalls *new connection handshakes* (observed 2026-07-14 ~03:00–03:15: first DB query of a test file times out at hook/test timeout; raw TCP fine, concurrent psql fine, postgres.js `CONNECT_TIMEOUT`; cleared by itself minutes later). Failure signature + probes documented in `handoff/20` §3.13.

24. **Catalog module v0 — BUILT (build 0.4, 2026-07-14; sheet 03 implemented).** The first module-table module and the second exemplar shape (record-module = sales-documents; table-module = catalog). As-built notes beyond the sheet:
    - Per-module migration path exercised in anger for the first time: `catalog/0001_catalog_items.sql` applied + tracked by the runner, with role provisioning correctly ordered before it (§9.23). RLS ENABLE+FORCE verified on the new table; grants exact.
    - Drizzle mirror defined against `schema.kernel` via the §9.23 builder re-exports; FKs/CHECKs live only in the SQL (mirror is for typed queries).
    - `findItem` tie-break: pg_trgm score DESC, then `price_amount ASC` — deterministic, and mirrors the old regex pricing where the base duration won ("Thai massage" → thai-60, not thai-90).
    - `get_catalog` returns the price BOTH formatted (`"900.00 THB"`) and in minor units — the model quotes the formatted one; minor units prevent arithmetic confusion.
    - **Composition lesson (record it once, reuse everywhere):** bridge event handlers run inside the *transitioning actor's* permission context. The catalog-backed `priceFor` calls `findItem` (requires `catalog.read`), so every role bundle that can mark a booking done needs `catalog.read` or the transition rolls back. Demo manifests: front-desk += `catalog.read`, manager += `catalog.*`.
    - Demo seeding inserts catalog rows via raw SQL guarded by `manifestModules().includes("catalog")` — deliberately NO static import of the module in `scripts/seed.ts`, so the template typechecks in generated apps without catalog. `lib/catalog-seed.ts` is plain data (same reason).
    - Composer updated: catalog registry row; `lib/hooks.ts` + `lib/catalog-seed.ts` are now GENERATED files (stubs); generated role bundles include `catalog.read`/`catalog.*` when selected; root `pnpm factory:create-app` script added (was documented but only existed in demo's package.json).
    - `/admin/catalog` is a hand-written shell page over the module's code API (gap 3 stands: generalize into a module-table scaffold when the SECOND module-table module lands).
    - Retired: `apps/demo/lib/pricing.ts` (regex pricing) → `apps/demo/lib/hooks.ts` (catalog-backed, async+ctx per §9.23).
    - MockAdapter now records `chatResults` (executed tool calls) for assertions — test infra, not a contract change.
    - Verified live: get_catalog offered + called by the real model ("How much is a Thai massage?" → 900 THB from data), booking→done invoice priced 90 000 THB-satang from the catalog row, admin catalog page + invoice Total column + contributed nav item all rendering; composer smoke app with catalog typechecked untouched.
    - NOT in v0 (per sheet §12): price lists/tiers, variants configurator (D8: variants are separate SKUs), images, cost/margin, bundles.

25. **contacts-crm v0 + the party surface — BUILT (build 0.4, 2026-07-14; sheet 01 implemented).** As-built notes beyond the sheet:
    - **Kernel v2.2 additions (the last planned UI-contract piece):** `ModuleDef.ui.partyTabs` — tabs on the shell's party detail page, descriptor+loader like recordTabs but with no target validation (Party is kernel-owned, the surface always exists); `KernelApp.partyTabs()`. And `setPartyTags(ctx, partyId, tags)` in the data core (audits `party.tags_updated` with from/to; no bus event — same stance as updateRecordData).
    - **Party shell surface** (`/admin/customers`, `/admin/customers/[id]` in the demo template): directory with tag filter; detail = contacts + tag editor + records linked via record_parties + partyTabs render + app-level Add-note and Open-lead forms. The mutation forms are app code calling module exports (the app knows its composition); tabs stay read-only descriptors.
    - **Append-only notes are grant-enforced**: `crm_notes` grants only `SELECT, INSERT` and REVOKEs the rest — this is the first in-anger proof that §9.23's one-time baseline grant makes module REVOKEs durable (verified surviving a migrate re-run). The sheet's original in-code-only stance is superseded (sheet updated).
    - `party.*` namespace stays kernel-unclaimed; crm surfaces gate under `crm.*` via `permissionNamespaces` (sheet §5 as written). `ModuleMeta` gained `permissionNamespaces` so composer role bundles include `crm.*` for managers.
    - `lead.convert` is declared and bundled into roles, but the shell currently enforces NO per-transition permissions for ANY entity (transitionAction under runAsUser doesn't gate `booking.confirm` either) — pre-existing, now explicit. Added to the mechanical backlog (handoff/20 §7.10): a `${entityType}.transition.${to}`-style shell check, one place, all entities.
    - `onEnter.converted` publishes `lead.converted {leadId, partyId}` only when a party link exists (audits regardless) — the emits contract requires partyId.
    - `apps/demo/scripts/verify-crm.ts` = repeatable live-verification op (like transition-latest).
    - Verified live: customers directory + tag filter, party detail with notes tab + linked Appointment/Invoice/Lead records, lead scaffold list; psql evidence (lead converted, crm_notes row, `lead.converted` + `party.tags_updated` audits). Gate: 8 typechecks, 57 tests.
    - NOT in v0 (per sheet §12): segment UI (code API only — tested, no screen yet), query-DSL segments (permanently), pipeline kanban, lead scoring, CSV import, merge UI.

26. **tasks-workflow — BUILT (build 0.5, 2026-07-14) by an Opus 4.8 pilot session, first module implemented purely from a written sheet** (renumbered from the pilot's §9.24 at merge — parallel sessions can't see each other's numbering; Fable-reviewed and merged same day, no correctness fixes required.) `packages/modules/tasks-workflow`: `task` record (`open → in_progress|done|cancelled`, `open → done` direct), `task.overdue` emit, one-shot `task.due` job, `tasks-mine` widget. Kernel untouched — the §9.23 pre-closures (`updateRecordData`, module drizzle exports) were exactly what the sheet needed, which is the first evidence that front-running the gaps worked. Decisions and deviations:
    - **The module owns its write path.** `createTask` / `updateTask` are exported and are the only supported way to write a task: the kernel's `createRecord`/`updateRecordData` are generic and (deliberately, §9.23) do not validate `data`, so zod parsing, `assertKnownRefType` on `data.source`, and the due-job enqueue live in these two functions. The sheet's "enqueue at write time, not `onEnter`" holds: `dueAt` exists from `open` onward and changes without a transition, so a state hook could never be the enqueue point. Consequence for later modules: **a module with a write-time side effect must export its write path** — an app calling bare `createRecord` silently skips it.
    - **Stale-guard is the arbiter; old jobs are not cancelled.** A reschedule enqueues a NEW `task.due` and leaves the old one queued (pg-boss has no cancel-by-key). The handler compares `payload.dueAt` to the record and exits silently when the task is gone, `done`/`cancelled`, or rescheduled. It loads the task with a direct `select` rather than `getRecord` on purpose: an orphaned job (record deleted — routine in tests against the shared DB) must exit silently, not throw `NotFoundError` and retry.
    - **Not guarded: pg-boss at-least-once delivery.** A retried delivery of the same job would emit `task.overdue` twice. Left unguarded in v0 because overdue is a *fact*, not a side-effecting notification (nothing consumes it yet). The first consumer (a notifier module/bridge) must either dedupe or pull an audit-based guard here — decide then, in a new §9 entry.
    - **Audit `task.due.scheduled`** added at enqueue (mirrors `booking.reminder.scheduled`). Audit types are free-form; only *bus* events need an `emits` contract — worth knowing when reading `emits` as if it were the full event surface.
    - **Widget with no `userId`** (system/job context) returns the all-open count with hint `"all staff"`, per the sheet — never throws.
    - **`vitest testTimeout: 60s`** in this package alone: `tasks-flow.test.ts` is the first test that drives pg-boss for real (the enqueue), and its cold start — `boss.start()` + `createQueue()` against the shared remote Postgres, before the `task.due` queue existed — blew the standard 30s budget on the very first run (§9.23 test-gate note / `handoff/20` §3.13). Warm, that test costs ~9s.
    - **Test composes `scheduled-interactions`** (devDependency, like sales-documents does) so the task's generic `data.source` ref points at a real booking — the cross-module case `assertKnownRefType` exists for, proven without `tasks-workflow` depending on that module. The test drives the real enqueue, then invokes the `JobDef` handler in the same context the worker builds (`runAsSystem` + the job's declared permissions) instead of sleeping until `dueAt`; the *real* pg-boss → worker → `task.overdue` path was verified live in the demo tenant instead (`docs/handoff/40` recipe: `scripts/create-task.ts` + `pnpm --filter demo worker`).
    - **NEW contract gap (registered as gap 12, authoring guide):** the admin shell scaffolds list/detail/**transition** only — there is **no create or data-edit form**. The sheet's acceptance said "create via admin UI or a tsx script"; only the script exists, so `apps/demo/scripts/create-task.ts` ships as the demo's create path (and as the live-verification recipe). Every sheet whose narrative starts with an operator *creating* a record hits this. Generalizing a create/edit form from `dataSchema` (a zod → form renderer, same descriptor philosophy as `listColumns`) is the obvious next kernel/shell move — but it is a shell contract change, so it wants its own §9 entry, not a drive-by.
    - Manifest role bundles for demo + tutoring gained `task.*`; roles are materialized at **seed**, so existing seeded tenants keep their old bundles until re-seeded (harmless: the admin surfaces don't gate on permissions today).

27. **Chat spend guards — BUILT (build 0.5, 2026-07-14; `handoff/20` §7 work order 1) by an Opus 4.8 pilot session** (renumbered from the pilot's §9.24 at merge; Fable-reviewed and merged same day, no correctness fixes required). Two independent guards, because they fail differently: a *flood* is an edge problem (many cheap requests), a *budget* is a tenant problem (accumulated cost). Nothing here is manifest-configurable — limits are environment config (D8: manifests carry data/copy, never runtime behavior); only the denial *copy* resolves through the manifest, like every other customer-facing string.
    - **Rate limit (app edge, `/api/chat`):** a pure in-memory `TokenBucket` in the kernel (`packages/core/src/rate-limit.ts`); the app owns the policy (`apps/demo/lib/rate-limit.ts`). **Two buckets, both must allow:** per `conversationId` (`CHAT_RATE_LIMIT_PER_CONVERSATION`, default 10/min) *and* per client IP (`CHAT_RATE_LIMIT_PER_IP`, default 30/min). The IP bucket is not redundant: `conversationId` is client-supplied, so a rotating id would reset the conversation bucket for free. Denials do not deduct tokens (hammering cannot extend your own penalty), and idle keys are evicted (a bucket refilled to capacity is indistinguishable from one that never existed). Enforced BEFORE the kernel is called, so a flood costs neither tokens nor a DB write. **Single-node by construction** — the buckets are per Node process, which is exactly right for the Coolify one-web-node target and wrong the moment a second replica appears. Upgrade path: keep the `TokenBucket` interface, back it with Postgres (`kernel.rate_limits`, `UPDATE … RETURNING` refill) or Redis; the call site in the route does not change.
    - **Daily budget (kernel, `KernelGateway`):** `KERNEL_DAILY_TOKEN_BUDGET` (default 500_000 input+output tokens per tenant per **UTC** day; 0 = unlimited) is checked by summing `ai_usage` inside `ctx.tx` **before the provider call** — a denied call must not burn provider tokens. The window is the UTC day computed in JS and passed as a bound, not `date_trunc` in SQL (no dependence on the server's `TimeZone`).
    - **Denial semantics, deliberately asymmetric:** `chat()` denies **in-band** — it returns a normal `ChatResult` with friendly copy, zero usage, and `stopReason: "budget_exceeded"` (exported as `BUDGET_STOP_REASON`). Its caller is a customer conversation, so the reply *is* the denial: no module code changed, the friendly line persists as a normal assistant turn (conversation memory stays coherent), and the customer gets HTTP 200 rather than an error. `object()`/`embed()` **throw** `BudgetExceededError` instead — their callers are internal code that must not silently receive a fabricated answer. Copy default lives in code and is overridable via the manifest `prompts` key `ai.budget_exceeded` (a Thai vertical needs a Thai denial); this is copy, not behavior, so D8 holds.
    - **Per-call ceilings:** `AIContext.maxTokens` is now clamped, not merely defaulted — `min(requested ?? KERNEL_MAX_TOKENS_DEFAULT, KERNEL_MAX_TOKENS_CAP)` (4096/8192). `maxToolRounds` gained the same treatment (`KERNEL_MAX_TOOL_ROUNDS_CAP`, default 8) because every extra round is another billed provider call — an unbounded number from module code was the same class of hazard the work order named for `maxTokens`. `CreateKernelOptions.aiLimits` overrides any of these at composition time (this is what the tests use; no env mutation).
    - **The route no longer 500s at a customer:** a rate limit returns 429 + `Retry-After` + a `reply` string; an inbound failure returns 503 + `reply`; the chat client renders `reply` as a notice and restores the unsent draft instead of silently swallowing the message (it previously dropped failed sends on the floor).
    - Verified live on :3151 (spend: 2 real messages): budget-1 boot → `ai.chat.denied` trace with **no** `ai.chat.start`, friendly turn persisted, zero `ai_usage` rows; limit-2 boot → two grounded Claude replies, then 429/`Retry-After: 17`, with the denied POSTs persisting *nothing*. Tests: `rate-limit.test.ts` (pure, injected clock) + `ai/budget.test.ts` (real DB + `MockAdapter`, one tenant per case; asserts the provider is never reached when over budget).
    - **Known limits, accepted:** the budget check adds one aggregate `SELECT` per chat call and `ai_usage` has no `(tenant_id, created_at)` index — fine at demo volume, worth an index migration when a tenant's daily row count gets large (deferred: no new migration in this work order). The budget is *pre-call*, so a single call may overshoot the cap by its own size; and because metering lives inside `ctx.tx` (§9.11), usage lost to a rollback is also invisible to the budget. Cost-based budgets (USD) need a per-model price table — the `ai_usage.cost_usd` column exists and stays unpopulated until then.
28. **Internet-business expansion — DECIDED (2026-07-17, James).** The factory explicitly serves **internet businesses** (marketplaces, SaaS, content platforms) alongside the offline-SMB products, via the same codegen. Strategy prose: `handoff/10` §9. The engineering principle James approved: **socket-and-plug, never a hole** — the kernel/module layer defines one thin contract (the socket) for each externally-provided concern; each generated business supplies its provider adapter (the plug) as ordinary manual code. Two concerns are permanently plug-territory: **user authentication providers** (Clerk, LINE Login, …) and **payment gateways** (regionally divergent: Omise vs Stripe vs PromptPay). The kernel owns the *internal* identity model and the *money facts*; it never owns the provider integration.
    - **Validation exemplar: a Cameo-style video-shoutout marketplace MVP** (`apps/cameo-demo`), scope fixed with James: mock payment adapter (full 75/25 fee math recorded as facts, no PSP), seeded talent accounts (no self-serve signup), tokenized guest links for buyers (no buyer accounts), video = upload to S3-compatible storage + playback from the same source (hard size cap, no transcoding/CDN), auto-expiry of unanswered requests, platform admin via the existing back office. Explicitly out: notifications, discovery ranking, anything Cameo-at-scale.
    - **Phase structure with the retention property** (James's requirement: stopping at any phase loses nothing): Phase 1 = these designs (§9.28–§9.32 + sheets 16/17). Phase 2 = kernel sockets (§9.29 identity, §9.30 public surface) — stopping here retains the complete enhanced factory, zero Cameo. Phase 3 = modules (amended `payments`, amended `documents-files`, new `marketplace-listings`, `marketplace-orders`) — factory catalog assets, Opus-dispatchable per `60-opus-execution-protocol.md`. Phase 4 = the Cameo mint + live verification (the only Cameo-only phase). Phase 5 = real plugs (Clerk/Omise/…), per-business forever.
    - Layer discipline makes the separation structural: nothing marketplace-flavored enters `packages/core`; nothing Cameo-flavored enters `packages/modules/*`; Cameo specifics live only in `apps/cameo-demo` + its manifest.
    - **Status (end of 2026-07-17 session): Phase 1 DONE (this entry + §9.29–§9.32 + sheets 16/17); Phase 2 DONE (both kernel sockets built + tested, §9.29/§9.30); Phase 3 payments BUILT (§9.32).** Remaining, all Opus-dispatchable work orders: documents-files (sheet 11 + §9.31), marketplace-listings (sheet 16), marketplace-orders + settlement bridge (sheet 17), then the Phase 4 Cameo mint (`handoff/20` §7 item 16). James input still pending only for: MinIO-vs-real-bucket at documents-files build time.

29. **Identity socket ("principals") — DESIGNED & BUILT (2026-07-17, build 0.6, same session).** Implemented exactly as designed below; migration `0002_identity_socket.sql` applied to the shared DB; 6 integration tests (`tenancy/principals.test.ts`) cover portal resolution (active/disabled/cross-tenant), guest issue→resolve→revoke→expire, wrong-secret + cross-tenant token rejection, no-secret-in-audit, and party-scoped listing. As-built module: `packages/core/src/tenancy/principals.ts`. The kernel generalizes "who is acting" from `userId?` to a **Principal**: `{ kind: "staff", userId }` | `{ kind: "portal", partyId, accountId }` | `{ kind: "guest", grantId, subjectType, subjectId, partyId? }` | `{ kind: "system" }`. `ActionContext` gains optional `principal` (source of truth) and `partyId` (convenience, set for portal + party-bearing guest). All existing paths are untouched: staff/system code keeps working with `userId?`/explicit permissions; the change is additive.
    - **Portal principals** (a Party member who can log in — marketplace talent, later client-portal customers): new identity table `kernel.party_accounts { id, tenant_id, party_id → parties, auth_user_id (unique per tenant), permissions: jsonb string[], status: active|disabled, created_at }` — **deliberately OUTSIDE RLS** like `users` (read while establishing context; same trust class). Permissions are assigned at provisioning (seed/invite) from a manifest role bundle; the staff `roles` machinery is NOT reused (portal grants are a fixed narrow set, not operator role administration). `KernelApp.runAsPortal({ authUserId }, fn)`: resolve active account → ctx with the account's permissions, `principal: portal`, `partyId`. **Party-scoping is code-level, not RLS**: RLS guarantees tenant isolation (hard); "portal sees only their party's records" is enforced by kernel helpers (`listRecordsForParty(ctx, { partyId, type? })` joining `record_parties`) + module discipline in portal-facing functions (`requirePortal(ctx)` returns the partyId or throws). Same trust level as staff RBAC today; recorded honestly.
    - **Guest principals** (link-holders, no login — the buyer's order page): new business table `kernel.guest_grants { id, tenant_id, party_id?, subject_type, subject_id, permissions: jsonb, secret_hash (sha256 hex), expires_at, revoked_at?, created_at }` — **UNDER RLS** (ENABLE+FORCE; verified inside `withTenant`). Token format `<grantId>.<base64url(32 random bytes)>`; only the hash is stored; verification = RLS-scoped lookup by id + `timingSafeEqual` on sha256 + expiry/revocation check. DB-backed (revocable, auditable) over stateless HMAC (key-rotation pain) — one indexed read per request is nothing at this scale. `issueGuestGrant(ctx, { partyId?, subject: EntityRef-shaped {type,id}, permissions, ttlDays })` → `{ token, grantId, expiresAt }` (plaintext exists only in the return value; never logged; audited as `guest.grant_issued` with subject+permissions, never the secret). `revokeGuestGrant(ctx, grantId)` (audited). `KernelApp.runAsGuest({ token }, fn)` → ctx with exactly the grant's permissions + `principal: guest`. `assertGuestSubject(ctx, type, id)` throws `PermissionDeniedError` unless the principal is non-guest or the grant's subject matches — every guest-facing read path calls it. Bookmarkable-URL tradeoff is deliberate and documented: HTTPS assumed, TTL default 30 days, revocable.
    - **The authenticator stays app-layer** (this IS the socket): the kernel never imports Better Auth — it only defines the mapping tables + `resolveStaff` / `resolvePortalAccount` helpers. Better Auth remains the demo/template authenticator for BOTH staff and portal (one `ba_users` account; what differs is which mapping row exists). A Clerk/LINE-Login build swaps the app-layer authenticator and calls the same mapping helpers — zero kernel change. Seeded talents = seed script does `auth.api.signUpEmail` + party + `party_accounts` row (mirror of operator seeding).
    - **Migration `0002_identity_socket.sql`**: both tables + explicit grants to `ai_kernel_app`; RLS ENABLE+FORCE on `guest_grants` only, with the standard tenant policy; comment in the SQL explaining why `party_accounts` is not RLS'd. (§9.23 default-privileges make DML grants automatic, but explicit grants stay per house rule.)
    - **Deliberately NOT in this design:** self-serve signup flows (a plug, not the socket), password reset / email verification (authenticator concerns), per-party RLS (invasive; revisit only if a portal module's threat model demands it), cross-tenant portal accounts.

30. **Public-surface contract — DESIGNED & BUILT (2026-07-17, build 0.6, same session).** Implemented exactly as designed below; 4 tests (`sdk/public-data.test.ts`): namespace/duplicate boot rejection, loader listing, tenant-scoped runPublic with zod param validation + NotFound on unknown names. Modules can expose data to UNAUTHENTICATED visitors — closed by default, explicit per item. Rejected: extending the descriptor+loader UI pattern to public pages (public pages are the product's face; a generic renderer would fight design quality, and D8 says generate real code instead). Adopted: **modules contribute named public data loaders; apps own the public React entirely** (codegen stamps the pages later).
    - `ModuleDef.publicData?: Array<{ name, params: zodSchema, load: (ctx: PublicContext, params) => Promise<unknown> }>`. `name` is namespaced exactly like permissions (must live in a namespace the module owns, e.g. `listing.browse`); boot validation rejects unowned namespaces and duplicate names across modules (same failure style as event contracts).
    - `PublicContext = { tenantId, tx, runtime }` — **no userId, no permissions, no principal**. The loader itself is the allowlist: it must select only public-safe fields. That discipline is enforced by review + the naming rule (a loader is a *published* surface, greppable as `publicData`), not by a second permission system — honest and thin.
    - `KernelApp.runPublic(name, params)` → zod-validate params → `withTenant` → run the loader. Unknown name throws `NotFoundError`. RLS still applies underneath (tenant isolation is never waived). Rate limiting is the app edge's job with the existing `TokenBucket` (same pattern as `/api/chat`); note in every public route.
    - No caching, no pagination contract in v0 (loaders may accept `limit` params; first module that needs real pagination proposes it here).

31. **Media — DECIDED (2026-07-17): NO new kernel socket; the storage seam lives in `documents-files` (sheet 11), AMENDED for large media.** The kernel `attachments` table already exists and suffices. Sheet 11 changes (recorded there as an amendment section, this entry is the authority): (a) the **S3-compatible `StorageProvider` is promoted into v0** alongside LocalDisk (`@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner`; `endpoint` + `forcePathStyle` config so MinIO/R2/real-S3 all work); (b) `StorageProvider` gains optional `presignedPutUrl(args)` and required `head(storagePath)`; (c) **two upload modes**: route-streamed multipart (sheet's original; small docs) and **presigned direct-to-storage** for video — `beginPresignedUpload(ctx, meta)` → client PUTs to storage → `confirmPresignedUpload(ctx, meta)` which `head`-verifies (exists, size ≤ cap, content-type matches) then inserts the attachments row + audit + `document.uploaded` event. Unconfirmed uploads leave orphan objects — accepted v0 debris (GC deferred, consistent with the sheet's orphan stance); (d) factory opts widen: `documentsFilesModule({ attachTo, maxBytes?, allowedMime?, presignTtlSeconds? })` — the marketplace app passes video mime types + a 200 MB cap; defaults stay the sheet's 10 MB/docs allowlist. Env (closes gap 11): `KERNEL_STORAGE=local|s3`, `KERNEL_STORAGE_DIR`, `KERNEL_S3_ENDPOINT/REGION/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY/FORCE_PATH_STYLE` → `.env.example`. Playback = `signedUrl` (S3 presigned GET, short TTL; LocalDisk streams through the authenticated/guest route). **James decision needed at build time, not now: MinIO-via-Docker locally vs a real bucket.**

32. **Payments AMENDED for marketplace facts (2026-07-17; sheet 05 gets an amendment section, this entry is the authority) — and BUILT same session (build 0.6, `packages/modules/payments`, 6 acceptance tests).** As-built deviations from sheet 05: (a) `paymentsModule` is a plain ModuleDef, NOT a factory — providers never enter the module def; they are app-composition hooks passed to webhook routes and bridges (the `priceFor` pattern, D8-consistent); (b) `payment.confirmed` payload generalized to `{ paymentId, sourceType, sourceId, amount, currency }` (the sheet's `invoiceId` was invoice-era; no consumers existed); (c) the webhook route ships with the first app composing a REAL provider (mock has no webhook); (d) payout parties: the payee is linked as record party role `payee`. Bridge behavior verified: per-currency settlement, wrong-currency payments never count, idempotent on paid invoices, Payments tab renders. Four changes, all compatible with the sheet's invoice-settlement design:
    - **Decoupled from sales-documents:** `dependsOn` drops to none — `data.source` is validated by `assertKnownRefType` against whatever the composed app registers (an invoice in the SMB products, a `video_request` in the marketplace). `invoicePaymentBridge` keeps `dependsOn: ["payments","sales-documents"]` and acts only on invoice-sourced payments. The marketplace equivalent (`marketplaceSettlementBridge`, sheet 17) ships with marketplace-orders.
    - **New record type `payout`** (money OUT — the sheet's "not this module" stance is superseded for *facts only*; actual transfer execution remains a plug): machine `pending → released | cancelled`; record money = **net amount payable**; `data = { source: EntityRef, payeePartyId, feeAmount?: minor-int, feeCurrency?, note? }` (fee recorded so the platform's take is queryable); permissions `payout.read|create|release|cancel`; emits `payout.released { payoutId, payeePartyId, amount, currency }` from `onEnter(released)`. v0 release is an operator action (simulates the transfer); a real payout rail is a future provider plug.
    - **`mockPaymentProvider()`** ships in the module (the MockAdapter move, §9.13): deterministic `providerRef`s, `createPaymentIntent` succeeds instantly, `verifyWebhook` returns null (mock flows transition directly, no webhook). This is what the Cameo MVP composes; a real Omise/Stripe plug later implements the same `PaymentProvider` with zero schema change.
    - **Marketplace state mapping documented, machine unchanged:** `pending` = authorized/held, `confirmed` = captured, `failed` = declined/voided, `refunded` = refunded. A hold-then-capture PSP adapter maps its states onto these four; the facts ledger never forks per provider.

33. **documents-files — BUILT (build 0.7, 2026-07-18; sheet 11 + §9.31 amendment implemented by an Opus 4.8 session).** `packages/modules/documents-files`: the storage seam + code API over the pre-existing kernel `attachments` table. **No entity, no module table, no migration** — behavior only, exactly as §9.31 decided; `db:migrate` was out of scope, the lowest-risk build on the board. Composed into `apps/demo` (Files tab on `booking/quote/invoice/task/lead`), routes `POST/GET /api/files` + `GET /api/files/[id]`. **James's storage decision (the one §9.31 deferred): MinIO** (S3-compatible), host `46.247.108.78:9000` from `.env` `MINIO_*`. Gate: `pnpm typecheck` + `pnpm test` green at root (6 acceptance tests, real DB, LocalDisk temp dir). **Live-verified against the real MinIO** (5 steps: route-streamed upload → object in bucket; download checksum; `signedUrl` presigned-GET playback; `beginPresignedUpload`→client-PUT→`confirmPresignedUpload` video path; delete removes row + object). As-built decisions & deviations from the sheet:
    - **Both providers built** behind one `StorageProvider` (`./storage.ts`): `LocalDiskProvider` (default; key `<tenantId>/<attachmentId>`, `local:<key>` locator, path-traversal-guarded) and `S3Provider` (`@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner`, `endpoint` + `forcePathStyle` → MinIO/R2/S3 interchangeable). Config-selected from env by `resolveStorageProvider()` (cached singleton), **kept in the module — never on `ctx.runtime`** (no kernel change, per §9.31). The demo defaults to `KERNEL_STORAGE=local`; the MinIO run set `KERNEL_STORAGE=s3` + `KERNEL_S3_*` mapped from `.env`'s `MINIO_*`.
    - **`get()` returns a `Buffer`, not the sheet's `Promise<ReadableStream>`** (deviation): v0 buffers whole bytes because the route path only ever serves the ≤10 MB doc allowlist; large media is played back via `signedUrl` (never through the app process). Simpler and correct for the use case; revisit if a route ever needs to stream big files.
    - **Caps/allowlist are a per-call `AttachmentPolicy` on the standalone functions**, not baked into the factory (deviation from §9.31's "factory opts"). Module code-API functions are standalone exports (catalog precedent) and can't close over factory opts, so each app route states its own policy — the SMB route uses the exported defaults (10 MB + image/PDF/office allowlist), a video route passes `{ maxBytes, allowedMime }`. The factory opt therefore narrowed to `{ attachTo }` for v0. Tests inject a temp `LocalDiskProvider` the same way (`policy.provider`).
    - **Files tab = one identical descriptor per `attachTo` type; the app owns the loop** (sheet §8; the `forEntityType` wildcard stays authoring-guide gap 3 — proposed, not shipped). Permissions `document.{upload,read,delete}` under `permissionNamespaces: ["document"]`; granted to the demo roles (`document.read`+`document.upload` to front-desk, `document.*` to manager).
    - **Two upload modes, both live-verified:** route-streamed (`uploadAttachment`, `put` BEFORE the tx insert → orphan bytes are the only acceptable rollback residue, never a row without bytes) and presigned direct-to-storage (`beginPresignedUpload`→`confirmPresignedUpload`, `head`-verify size/type then insert row+audit+`document.uploaded`). Delete order: row + audit inside the tx, then `provider.delete` LAST (a byte-delete failure rolls the tx back, leaving row+bytes consistent). Emits `document.uploaded { attachmentId, recordId?, filename, mimeType }`.
    - **Operator-authenticated only in v0** — no public/guest/portal path here (that arrives with the Cameo talent portal + guest playback, sheet 17/O4). `.env.example` gains the `KERNEL_STORAGE*`/`KERNEL_S3_*` keys (**authoring-guide gap 11 CLOSED**). Registry note: documents-files is a *factory* top-level module (app supplies `attachTo`); `ModuleMeta` has no factory-opts field, so it's recorded in the row comment + here. One live surface deferred to James: a browser-session HTTP smoke of the routes (needs Better Auth login) — the underlying functions + the tab loader are verified.

34. **marketplace-listings — BUILT (build 0.7, 2026-07-18; sheet 16 implemented by an Opus 4.8 session, same session as §9.33).** `packages/modules/marketplace-listings`: the public two-sided provider directory and **the first consumer of both new sockets** — §9.29 identity (portal self-edit) and §9.30 public surface (two loaders). Module-owned table `mkt_listings` (migration + RLS/FORCE + explicit grants; one listing per party via `UNIQUE(tenant_id, party_id)`); applied to the shared DB via `db:migrate`. Gate: `pnpm typecheck` + `pnpm test` green at root (8 acceptance tests, real DB: slug normalization, browse active-only + public-safety, category/fuzzy filter, profile-by-slug + null-for-paused, sampleUrl hook, portal read/edit own + party-filter block, `listing.self` permission gate, RLS). As-built decisions & deviations:
    - **Public loaders enforce the §9.30 red lines:** `listing.browse` (active only, pg_trgm `word_similarity` over headline+category, joins the party **NAME only** — never `party_id`/contacts, price pre-formatted + minor units mirroring `get_catalog`) and `listing.profile` (one active listing by slug or `null`). Verified in-test that browse output has no `party_id`/`partyId`/`bio` keys.
    - **The module is a factory `marketplaceListingsModule({ resolveSampleUrl? })`** — the sample-video playback URL is an **app-supplied hook** (documents-files `signedUrl`), so this module takes **no dependency on documents-files**; the kernel attachment id is the seam (same D8/hook discipline as payments' `priceFor`). Absent hook → profiles omit `sampleUrl`.
    - **Portal enforcement is party-filter-first:** `myListing`/`updateMyListing` call `requirePortal(ctx)` and hard-filter `party_id = ctx.partyId`; the patch excludes `active` (going live is a staff/moderation act) and `party_id`. A talent literally cannot target another's row (no id param) — proven in-test.
    - **Added `setListingActive(ctx, id, active)`** (staff moderation toggle) beyond the sheet's §12 list — the `/admin/listings` activate/pause action (§8) needs a by-id toggle; catalog's `setItemActive` is the precedent. Minor, recorded here.
    - **NOT composed into `apps/demo`** — a talent marketplace does not fit the wellness/tutoring vertical (precedent: payments is built but uncomposed). Ships as a **factory catalog asset**; the `/admin/listings` React page + app composition (with a real `resolveSampleUrl`) belong to the Cameo mint (O4). This is the **SECOND module-table admin surface** → authoring-guide **gap 3** (generic module-table scaffold) may now be designed — proposed here, not built.
    - **GAP found (registered, not free-lanced): no write-path sets `sample_attachment_id`.** Neither `upsertListing` nor `updateMyListing` accepts it in v0, so a talent cannot yet attach their intro video to their listing. The Cameo mint (O4) or a small follow-up needs one — extend `updateMyListing` to accept a validated `sampleAttachmentId` (attachment must exist + be this tenant's, RLS-scoped select) or add `setListingSample`. Left as a finding (the tab/loader read-path is done; the live test links the id directly via admin DB to exercise the resolver).
    - Refs: `mkt_listings.id` is a bare uuid — **gap 9 stands** (no EntityRef validation for module-table ids); marketplace-orders (O3) re-validates existence + `active` at order time, per sheet 17 §7.

35. **marketplace-orders + marketplaceSettlementBridge — BUILT (build 0.7, 2026-07-18; sheet 17 implemented by an Opus 4.8 session, same session as §9.33/§9.34).** `packages/modules/marketplace-orders`: the transactional core of a request-based marketplace and **the heaviest consumer of the identity socket** — portal (provider) AND guest (buyer) principals both gate write paths (§9.29). `video_request` is a **record** (no module table, no migration). Gate: `pnpm typecheck` + `pnpm test` green at root (6 acceptance tests, real DB — the full request→accept→deliver→settle→guest→expire→release flow). As-built decisions & deviations:
    - **Machine** `requested → accepted|declined|expired`, `accepted → delivered|expired`, `delivered → completed`; `onEnter` publishes `mkt.order.closed`(declined/expired) and `mkt.order.delivered`. **Guards live in the write paths, not the machine** (spec §9.26): `deliverOrder` verifies the delivery attachment row exists in-tenant (RLS-scoped) and sets `data.deliveryAttachmentId` *before* transitioning; `expired` is job-driven only.
    - **Write paths are the only supported writers** (every transition carries side effects): `createVideoRequest` (validates the listing is active via `getListing` — the route grants `["mkt.order.create","listing.read"]`; upserts the buyer party by email; snapshots price+headline; links customer+provider; schedules the accept-window expiry; publishes `mkt.order.placed`; issues a **60-day guest grant** → returns `{orderId, guestToken, guestExpiresAt}`), `acceptOrder`/`declineOrder`/`deliverOrder` (portal, provider-matched via `requirePortal` + the `provider` record-party — the party match, not the permission, is the gate), `myOrders` (portal, `listRecordsForParty`), `orderForGuest` (guest, `assertGuestSubject`; the principal's `subjectId` IS the order — a token can only ever read its own order; playback URL via an app-supplied `resolveDeliveryUrl` hook).
    - **Two stale-guard jobs** (spec §9.26, tasks-workflow pattern): `mkt.order.expire` (accept + deliver phases; the deadline stored in `data` is the arbiter; direct select, silent no-op when gone/stale/already-moved) and `mkt.order.autocomplete` (`delivered → completed` at T+3d).
    - **Settlement bridge** (`marketplaceSettlementBridge`, ships in-package, `dependsOn: ["marketplace-orders","payments"]`): factory `{ feePercent, paymentProvider }` — **both app-supplied**. *Deviation:* sheet §11 named only `feePercent`; the `PaymentProvider` is a second required factory arg because §9.32 makes providers app-composition hooks (the mock in v0/Cameo; a real PSP later). On `record.video_request.created` → `createPayment` (pending) + `createPaymentIntent` → `providerRef`. On `→ delivered` → capture the payment (`pending → confirmed`) + `createPayout` (money = **net** = price − fee; `feeAmount`/`feeCurrency` recorded), **idempotent** (existing payout → no-op; no pending payment → skip capture; verified by replaying the transition event). On `→ declined|expired` → void the payment (`pending → failed`; terminal → no-op). Payout release stays a **manual operator transition** in v0. A "Settlement" tab surfaces the payment+payout on the order page.
    - **Why the bridge works in a portal/guest/job ctx:** `createRecord`/`transitionRecord` (and thus `createPayment`/`createPayout`) do **not** self-check RBAC — permissions gate the module entry points, the raw record ops are trusted. So the bridge settles correctly even when a portal principal (only `mkt.order.self`) triggers `delivered`. Same discipline `invoicePaymentBridge` relies on.
    - **NOT composed into `apps/demo`** (marketplace ≠ SMB vertical). Ships as a factory catalog asset; the public request route, talent portal, guest order/playback page, seed, and app composition (with a real `resolveDeliveryUrl` + fee) are the **Cameo mint (O4)** — the only remaining Cameo phase.
    - **Root-gate note (pre-existing infra, not a regression):** `turbo run test` runs package suites in parallel, which contend on the shared remote Postgres and produce transient connect-stall failures that **move between untouched packages** (§3.13). Every package passes individually; the reliable full gate is `turbo run test --concurrency=1` (used for the green above). This strengthens the case for **O21** (per-checkout fixture prefixes) now that the module count is higher.

36. **Cameo MVP mint — BUILT (build 0.8, 2026-07-18; `apps/cameo-demo`, O4 / handoff-20 §7 item 16, by an Opus 4.8 session). Phase 4 done → the entire Cameo arc (O1–O4) is complete.** The first minted app that composes the marketplace stack end-to-end and the **first public/portal/guest surface** in the repo. James's scope call: a **functional slice** (whole loop working, minimal styling), **live-verified against real MinIO**. What's composed: `payments` + `marketplace-listings` (with `resolveSampleUrl`) + `marketplace-orders` + `marketplaceSettlementBridge({ feePercent: 25, paymentProvider: mockPaymentProvider() })` + `documents-files({ attachTo: ["video_request"] })`, one manifest ("Shoutout", placeholder). Live verification (`apps/cameo-demo/scripts/verify.ts`, self-cleaning): request → talent accept → **real video upload to MinIO** → deliver → settlement (payment captured, ฿375 payout after the 25% fee on ฿500) → **guest presigned-URL playback streaming the identical bytes**. Public pages curl-verified over HTTP (browse + profile + multi-currency). As-built + learnings:
    - **Surfaces built:** public `/` (browse, `listing.browse`) + `/t/[slug]` (profile, `listing.profile` + request form) → `requestAction` runs `createVideoRequest` under `runAsSystem` with exactly `["mkt.order.create","listing.read"]` (listing resolved by slug in the action) → redirects the buyer to `/order/[token]` (guest page, `runAsGuest` + `orderForGuest`, video playback when delivered). Talent portal: `/login` (Better Auth) → `/portal` (`runAsPortal` → `myListing`/`updateMyListing`, `myOrders`, accept/decline/**deliver**). One Better Auth setup serves both audiences (staff→`users`, talent→`party_accounts`), per §9.29.
    - **Multi-currency (James's O4 call) — display-only, no FX at checkout.** The money model is already per-currency + no-FX (§9.7); `lib/currency.ts` adds a viewer toggle (THB default ⇄ USD, extensible) using a **static rate table**, labelling conversions "≈" and showing the native price alongside. The buyer is charged the talent's **native** currency; settlement never converts. Seed mixes THB + USD listings so it's visibly real. **Deferred (deliberately): FX-at-checkout / live rates** — they'd fight the money-facts ledger.
    - **The talent-portal upload path (which §9.33 flagged as documents-files' deferred piece) needed NO kernel/module change:** `uploadAttachment` runs fine in a **portal** ctx because the party_account is granted `document.upload` at provisioning (talents get `["listing.self","mkt.order.self","document.upload","document.read"]`). The deliver action uploads to MinIO then calls `deliverOrder` in one portal tx; the bridge settles on the transition.
    - **Storage must be S3 for guest playback:** the app runs with `KERNEL_STORAGE=s3` (MinIO) so `signedUrl` (presigned GET) works; LocalDisk has no signer. Delivery upload goes through a **server action** (route-streamed) with `next.config` `serverActions.bodySizeLimit` raised; **production should use documents-files' presigned direct-to-S3 path** (§9.31) rather than streaming big video through the server — noted, not done in the slice.
    - **Template drift / firsts:** no public-page pattern existed (apps own public React, §9.30) — this establishes it. `apps/cameo-demo` is NOT wired into the composer registry codegen yet (hand-composed); generalizing the mint is F2 (composer archetype layer).
    - **Deliberately NOT in the slice (James reviews, then these follow):** visual **design pass** (functional Tailwind only); **staff admin** (moderation: activate listings, release payouts — listings seeded active, payout release via `payout.release` transition / a script; reuse the demo shell later); **talent sample-video upload** (the O2 `sample_attachment_id` write-path gap — delivery is the demo's star); a **real payment provider** (mock composed; Omise/Stripe is a drop-in plug); brand/name/copy.
37. **Design pass v2 — S4 (2026-07-30, Fable session; presentation layer only, `apps/demo` = the template every mint inherits).** Extends §9.20's token system with a **runtime derived tonal family**: `globals.css` computes `brand-soft/wash/ink/line` via `color-mix(in oklab, var(--brand) …, var(--card|--surface|--ink|--edge))` inside `@theme inline` — ONE manifest hex now yields chip fills, page washes, tinted text/borders for every vertical skin; **no ThemeTokens/manifest change** (kernel contract untouched). Display face for brand-identity moments only = system serif stack (Iowan Old Style/Palatino/Georgia; `--font-display`, zero deps — renders natively on the buyer's iPhone). Admin shell fully **tokenized** (was hardcoded stone/emerald, which silently broke the re-skin promise) and made phone-first: sticky monogram header + scrollable pill nav w/ active state (`components/admin-nav.tsx` client island), "Today" greeting band on the dashboard, **card-per-row list views under `md`** (tables at `md+`) for entities/customers/catalog. Lifecycle states = semantic dot-pills (`components/state-pill.tsx`, deliberately NOT brand-tinted — same state must read the same in every skin); shared class constants in `components/ui.ts`. Chat reference surface elevated: brand-washed backdrop, serif welcome card, brand-soft chips, bubble timestamps, icon send, message-entrance animation (reduced-motion honored). `formatCell` dates → `dateStyle:"medium"/timeStyle:"short"`. **Gotcha for future sessions: Tailwind v4.3 silently drops MULTILINE declaration values inside `@theme` blocks** — keep every `@theme` value on one line (cost an hour; plain CSS in the same file compiled fine, which misdirects). Verified live on :3100 (chat AI reply, all admin routes 200) + headless-Chrome CDP screenshots at 375/1440. Not done here: barber-demo/cameo-demo restyle (they re-mint or hand-port from the template), dark mode, real Thai typography beyond the Noto Sans Thai fallback already in the stack.
38. **Clinic demo mint — S3 (2026-07-30, Fable subagent session; `apps/clinic-demo`, "Sirin Clinic", port 3300, tenant `clinic-demo`, THB/locale th).** First mint FROM the §9.37-restyled template — the tonal family carried over untouched (one hex `#0c5d5e` deep teal on warm sand; verified in rendered HTML + CDP screenshots at 375/1440). Content per `docs/sales/20-clinic-demo-content-spec.md`; **all 7 sales beats live-verified through the real chat endpoint** (Thai price quote ฿4,900 "ต่อจุด" from the catalog; discount hold-the-line; melasma-diagnosis refusal → ฿500 consult offer; closed-Monday refusal + Tuesday alternative; 19:30 ask → last-consult-19:00 refusal → 18:00 accept → `requested` booking in /admin; confirm→done → bridge auto-drafted the invoice at ฿4,900; English answered in English from the same grounding). As-built decisions & deviations:
    - **Grounding is deliberately exactly 4 chunks** (about/hours, services/prices, policies, aftercare — each bilingual TH/EN): `LexicalRetriever` k=4 has no score threshold, so every query retrieves ALL grounding and the compliance beats never depend on Thai-vs-English trigram luck. Treat this as the mint pattern for small grounding sets.
    - **Catalog names are bilingual on purpose** ("Botox โบท็อกซ์ — … (per area ต่อจุด)"): `findItem` matches by ILIKE/word_similarity on `name`, so the Thai query text must literally occur in the item name for get_catalog (and the invoice bridge's `priceFor`) to hit. Also extended the app's `catalog-seed.ts`/`seed.ts` with the pre-existing `description` column (per-item grounding notes: per-area wording, promo framing) — app-level only.
    - **Composer gaps found (template drift, no kernel/module change):** (a) generated `lib/kernel.ts` emits `documentsFilesModule` bare, but it's a factory — hand-fixed to `documentsFilesModule({ attachTo: [...] })` (registry still has no factory-opts field, §9.33 note stands); (b) generated seed.ts prints :3100 URLs regardless of `--port`; (c) template `scripts/transition-latest.ts` looks up the operator by email WITHOUT a tenant filter — with several seeded tenants sharing `owner@demo.local` it can grab the wrong tenant's operator; clinic verification transitioned by booking id with a tenant-scoped lookup instead. F2 (composer archetype) should fix all three.
    - **Content-spec mappings:** welcome copy → the `tagline` slot (the chat welcome card's only free-text line; TH version used, EN version dropped — the card heading is already English); chips = 4 TH + 2 EN (dropped the EN "Book a consultation" duplicate — 7 chips crowded the 375px card); "no-show history" → `cancelled` + explanatory note (bookingMachine has no `no_show` state — did NOT touch the module); booking deposit/cancellation kept as policy text only (payments unseeded, per spec). Lived-in seed via `scripts/seed-demo-data.ts` (kernel write paths as the operator, so the done booking's ฿6,900 invoice is genuinely bridge-drafted; `--fresh` wipes+reseeds this tenant only; the overnight `requested` booking is backdated to 23:41 by direct `created_at` update — the one honest hack).
    - **Screenshot-tooling gotcha:** Chrome CLI `--headless --screenshot --window-size=375,…` renders macOS-scaled (~1.2x) and fakes a horizontal overflow; CDP `Emulation.setDeviceMetricsOverride` (as §9.37 used) shows the truth (`scrollWidth` 375 = clean).
39. **Per-checkout test-fixture prefixes — BUILT (O21, 2026-08-06, Opus 4.8 session; test-infrastructure only, zero production code touched).** Closes the last "known open item" in `60-opus-execution-protocol.md` §3: DB fixtures carried FIXED slugs (`vitest-flow-b`), so two checkouts gating against the ONE shared remote Postgres deleted each other's rows mid-run — phantom failures in files nobody edited (diagnosed by pilot B; §9.35 re-flagged it as the module count grew). **Parallel `pnpm test` is now safe.** As-built:
    - **One helper, 20 lines of logic:** `packages/core/src/testing/fixtures.ts` → `fixtureSlug(name)` = `` `vitest-${fixturePrefix()}-${name}` ``, re-exported from `@factory/core/testing` (which already carried `MockAdapter`, so most test files gained no new import line). The prefix is **6 hex chars of sha256 of the checkout root** — the dir holding `pnpm-workspace.yaml`, walked up from `cwd`, so every package in one checkout agrees while turbo runs them from their own dirs. `VITEST_SLUG_PREFIX` overrides (normalised to `[a-z0-9]`, ≤12 chars) for a named CI lane.
    - **Why derive from the path rather than randomise per run:** cleanup must still be able to find the *previous* run's leftovers. A stable prefix means a run killed before `afterAll` is cleaned by the next run of the SAME checkout (the pre-existing `beforeAll → cleanup()` pattern keeps working unchanged); a random/PID prefix would leak rows forever. Distinctness between checkouts is what makes one checkout's `DELETE` unable to reach another's rows — no cleanup was weakened, every `cleanup()` still deletes by the same derivation it inserted by.
    - **Every colliding identifier is namespaced, not just tenant slugs** — party names (`rls.test.ts` deletes parties *by name*, globally, via the RLS-bypassing `adminDb`), portal `auth_user_id`s, the tasks-workflow staff email (`users.email` is globally UNIQUE), webchat conversation ids, and documents-files' `tmpdir()` storage directory (two checkouts shared one path on disk). 14 test files converted; a grep for a raw `"vitest-…"` literal in a test is now a review smell.
    - **One assertion tightened (not a behavior change):** `principals.test.ts`'s "no secret in the audit payload" case selected `audit_events` by type across ALL tenants through `adminDb()`; it is now tenant-scoped, so a concurrent checkout's rows can't enter the assertion. Same intent, immune to parallelism.
    - **New pure unit test** `packages/core/src/testing/fixtures.test.ts` (3 cases: stability per root, distinctness between roots, override normalisation) — the decision logic sits in a pure `resolveFixturePrefix({override, root})` so the test needs neither env mutation nor a DB.
    - **Docs updated in the same commit:** protocol §3's "do not gate in parallel" rule inverted (+ its prompt-template env-facts line now says `fixtureSlug()`), `handoff/20` §6 records the convention. **Legacy rows:** a scan of the shared DB at merge time found ZERO `vitest%` tenants left over (prior gates cleaned up), so no one-time sweep is owed; if a future scan shows some, they are pre-O21 orphans and safe to delete once every checkout is on this code.
40. **Conversations inbox + human takeover — BUILT (build 0.9, 2026-08-06; `handoff/20` §7 work order 3 / O13, by an Opus 4.8 session).** The sales-kit critic had to delete every "hands the conversation to your staff" claim because no handoff existed (`docs/sales/30-veto-surface.md` item 10); this makes the claim true. Kernel table `kernel.conversation_states` (migration `0003`, RLS ENABLE+FORCE + explicit grants, applied to the shared DB), one row per `(tenant, channel, conversationId)`, created lazily — **"no row" means active, so every pre-existing conversation keeps its prior behavior and an untouched thread costs no writes**. New kernel module `channels/conversation-state.ts`: `isBotPaused` / `getConversationState` / `setBotPaused` / `sendOperatorMessage` / `listConversations` / `conversationActivity`. Composed into `apps/demo` **and** `apps/clinic-demo` (`/admin/inbox` + `/admin/inbox/[conversationId]` + actions). Gate: `pnpm typecheck` green at root (15 packages); 7 new real-DB tests (`conversation-state.test.ts`, slugs `vitest-inbox-*`); registry/public-data/budget/flow-a re-run green. Live-verified on :3150 through the **real** Better-Auth-authenticated admin surface and the **real** server actions (`$ACTION_ID` form POSTs, not script shortcuts): live-Claude reply → operator's "Take over" → customer's next message **stored with zero `ai_usage` rows** → operator reply visible to the customer as "Demo Owner" → "Hand back to AI" → grounded Claude answer again (900/1,300 THB from the catalog). Decisions:
    - **The gate lives in `handleInbound`, not in the module binding** (deviation from the work order's "binding checks the flag"). Order is: `adapter.recordInbound` → check `bot_paused` → **return** (never throw — §9.11: a throw inside this tx rolls back the customer's own message) → else `binding.onInbound`. Putting it one layer up means every channel adapter and every module inherits takeover with zero code — the LINE adapter (§7 item 5) gets it free — and no module author can forget it. `handleInbound` now returns `InboundOutcome { paused }` (additive; callers ignoring it still compile).
    - **Operator replies travel the ordinary outbound path**, marked `meta.author = "operator"` (+ `operatorUserId`/`operatorName`); `isOperatorMessage(meta)` is the exported predicate. Consequence, deliberate: `conversationAsChatMessages` folds staff replies into the assistant turns, so **the AI resumes knowing what the human promised** (asserted in-test) and the business keeps one voice. The model is not told which turns were human — a per-turn attribution channel would be a gateway contract change with no consumer today.
    - **Replying takes over automatically** (the app action pauses, then sends). Two voices answering one customer is the exact failure this feature prevents, and a second button a busy receptionist must remember is how you get it. Handing back is the deliberate act — one "Hand back to AI" button. Pausing itself sends the customer **nothing**: no auto-copy to translate, no D8 creep.
    - **The paused customer is told, once, in-band:** `/api/chat` surfaces `paused` from the outcome and the chat client renders a notice (`chat.handover` manifest label, default "Someone from our team is replying to you here — one moment."). Silence after "can I speak to a person?" is a worse product than a sentence.
    - **BUG FOUND AND FIXED en route — transcript order was undefined.** Postgres `now()` is the *transaction* timestamp, so a customer message and the AI reply written inside one `handleInbound` tx carried an **identical `created_at`**; `listConversation`'s `ORDER BY created_at` had no tiebreak and the chat UI was ordering by luck. Fixes: `WebChatAdapter` now sets `createdAt: new Date()` explicitly on both directions (statement time), and `listConversation` + the inbox aggregate tiebreak on `(direction = 'out')` — within one transaction an inbound is always recorded before the reply it triggers. Any future adapter should set `createdAt` the same way. This surfaced only because the inbox is the first screen to *render* a stored transcript.
    - **Inbox list = one aggregate over `channel_messages`** (`GROUP BY` + `JOIN LATERAL` for the last message + `LEFT JOIN conversation_states`), returning preview/direction/time/count/paused/`awaitingReply`. No party-name enrichment: names would mean the kernel reading module-shaped `record.data` keys (layer violation) — the app may enrich later. Sorting is app-layer: *paused **and** unanswered* first, then recency, because that subset is the only genuinely urgent thing on the screen.
    - **Nav: shell-owned, not module-contributed.** The inbox belongs to no module (it is kernel-level, above every binding), so `apps/*/app/admin/layout.tsx` hardcodes the item next to "Today" — the first shell surface with no owning module. A generated app gets it because it inherits the template.
    - **Screen names introduced** (James's standing preference; no prior pattern existed in the repo — grep found none). `components/screen-name.tsx`: a `SCREEN` code-constant map + a `<ScreenName>` badge, fixed bottom-right, 10px, `text-soft/50`, `aria-hidden`, internal screens only. Cast: `/admin/inbox` = **Milhouse**, `/admin/inbox/[conversationId]` = **Ralph**. The existing admin screens are **not** retrofitted — that is a one-pass sweep across files other live sessions are editing, deliberately left to a session that owns the whole shell.
    - **Not built (named, not hidden):** no auto-resume timer (a thread stays paused until a human hands it back — a timer would re-arm the AI mid-conversation, the worst possible moment); no operator notification when a customer messages a paused thread (that is §7 item 4, and the inbox's "unanswered" state is the interim signal); no realtime push (the admin list is a normal server render — the operator refreshes; the customer's chat already polls at 3s); no per-permission gate on takeover (consistent with the shell's pre-existing no-per-transition-RBAC state, §7 item 10 — when that lands, `conversation.takeover` is the string to add); no unread/assignment model.
    - **Ops recipe:** `pnpm --filter demo exec tsx scripts/inbox.ts <list|show|pause|resume|reply> [conversationId] [text]` — the same kernel calls the screens make, tenant-scoped operator lookup (avoiding the §9.38(c) shared-email trap).
41. **Embeddable chat widget — BUILT (build 0.9, 2026-08-06; O16 / `handoff/20` §7 item 6, by an Opus 4.8 pilot session).** `/widget.js` + `/embed/chat` in `apps/demo` — the template every mint inherits. The sales beat it exists for: a prospect who already has a website pastes ONE script tag and their site has the AI receptionist, which removes the "we already have a website" objection without touching their site. **App layer only — zero kernel and zero module changes**; it is presentation over the existing public `/api/chat` (same two rate-limit buckets §9.27, same conversation-scoped tool identity, no new auth surface, no cookies). New files: `lib/widget.ts` (manifest → `WidgetConfig`), `lib/widget-script.ts` (the loader, a pure function of that config), `lib/widget-protocol.ts` (the postMessage contract, imported by BOTH sides), `lib/embed-headers.ts`, `lib/screens.ts`, `app/widget.js/route.ts`, `app/embed/chat/page.tsx`, `public/widget-test.html`, `scripts/verify-widget.mjs`. Gate: `pnpm typecheck` green at root; 12 new pure tests (`apps/demo/lib/widget.test.ts` — the first app-level suite in the repo). Live-verified in headless Chrome from a genuinely third-party origin (22/22 checks, screenshots at 1440x900 and 375x812; playbook §11).
    - **`/widget.js` is a route, not a `public/` file.** Generating it means the launcher's colour, radius, label and teaser come from `branding.theme` + `labels` — the bubble paints in the right brand colour *before* the iframe exists, and a manifest swap re-skins a client's site with no redeploy of anything they hold. Proven live: same code served `#43705b`/"Chat with Lotus Wellness" and, under `DEMO_MANIFEST=tutoring`, `#3a5bc7`/`0.625rem`/"Chat with Bright Steps". `dynamic = "force-dynamic"` is deliberate — `force-static` would bake the build-time vertical into the file; the 5-minute `Cache-Control` is what keeps it cheap. Two new manifest label keys, `widget.label` / `widget.greeting`: copy, not behaviour, so D8 holds.
    - **Shadow DOM is the isolation boundary, not a naming convention.** The widget mounts in a shadow root on a fixed, full-viewport, `pointer-events:none` host, so a client's `* { box-sizing: content-box }`, their `button { … }` reset and their `z-index: 9999` banner cannot deform it and it cannot reflow their document (the test harness ships all three hostile rules on purpose). Everything is one `<style>` inside that root; nothing is appended to their `<head>`.
    - **The iframe is lazy.** Nothing loads from our app until the visitor opens the panel — a pasted tag costs one ~14 KB script and zero requests to the kernel, the model or the DB. This is the difference between a widget a clinic keeps and one their web guy removes.
    - **The conversation id is held first-party, by the loader.** A third-party iframe's `localStorage` is partitioned (Chrome) or blocked (Safari, strict modes), so the iframe cannot be the durable home of the visitor's thread. The loader keeps the id on the CLIENT's origin and hands it over after the iframe posts `ready`; the iframe falls back to its own storage after 800 ms (direct visits to `/embed/chat`). Verified: a thread survived a reload *and* a viewport change.
    - **postMessage contract, asymmetric on purpose** (`lib/widget-protocol.ts`, imported by both halves so they cannot drift): the parent CAN verify us (it knows our origin and its own iframe handle) so it checks `event.origin` **and** `event.source` and addresses replies to our exact origin; the child CANNOT verify an unknown host domain, so it accepts any origin and therefore nothing sensitive ever travels parent → child. The only payload is a conversation id, which `/api/chat` already treats as client-chosen. Messages: child → `ready|close|resize`, parent → `init|layout|visibility`.
    - **Sizing is content-driven and provably non-circular.** The child reports `header + composer + list.scrollHeight`; `scrollHeight` is content height, independent of the panel height the parent then applies, so there is no feedback loop. The panel opens compact (529 px on a fresh welcome card) and grows with the thread (585 px after one exchange), clamped to `[420, min(680, viewport)]`. Growing reads as content arriving; the reverse would read as a glitch, which is why the CSS default is the small end.
    - **Phone behaviour is the launch-grade half.** Under 640 px the panel becomes a full-screen sheet (verified `375x812@0,0`), the launcher steps aside, the page behind is scroll-locked, `env(safe-area-inset-*)` pads the sheet when the client's page opts into `viewport-fit=cover`, and the host element tracks `window.visualViewport` so the iOS keyboard cannot cover the composer. Focus-on-open is desktop-only (auto-focus on a phone pops the keyboard before the visitor has read a word).
    - **Headers are scoped, and `X-Frame-Options` is deliberately never set** (it cannot express an allowlist and would override the CSP). `next.config.headers()` applies `frame-ancestors` + `X-Robots-Tag: noindex` to `/embed/:path*` only — verified present there and absent on `/chat`. `WIDGET_ALLOWED_ORIGINS` pins an allowlist (`'self'` is prepended); unset means open, which is what a self-serve pitch needs. **Deployment gotcha:** a proxy that adds `X-Frame-Options: SAMEORIGIN` globally must strip it for `/embed/*` or the widget renders blank on the client's domain.
    - **`ChatClient` gained an `embedded` prop rather than a fork** — one component serves `/chat` and the widget panel, so the §9.37 design language cannot drift between them. It also gained a real fix that benefits both: polling now **stops while the document is hidden** (a widget sits on every page of a client's site; a background tab must not keep hitting the API).
    - **Screen names introduced** (James's standing preference, first use in this repo): `lib/screens.ts` code constants, launcher = **Maggie**, panel = **Milhouse**. Customer-facing surface, so the name lives in a `data-screen` attribute only — never rendered, never translated. Two names because the bubble and the panel look nothing alike.
    - **`devIndicators: false`** added to `apps/demo/next.config.ts`: the demo is shown live from `next dev`, and Next's dev badge lands bottom-left of whatever document it is in — which for `/embed/chat` is *inside the customer's chat panel*. It was visibly overlapping the composer in the first screenshot run. Dev-only config; production builds unaffected.
    - **Known limits, accepted (not defects, but write them down):** (a) `GET /api/chat?conversationId=…` is unauthenticated and **not** rate-limited (only the spend edge, the POST, is — §9.27's deliberate scope), so a leaked conversation id is a capability URL to that transcript; ids are v4 UUIDs and unguessable, and the widget makes this pre-existing exposure more public rather than new. The fix, when a real client ships: sign the id (HMAC) or rate-limit the GET per IP. (b) The panel has no unread badge — the iframe does not exist while closed, which is the price of lazy loading; a badge needs either an eager hidden iframe or a cheap poll in the loader. (c) One widget per page (`window.__aiWidget` guard).
    - **Pre-existing finding, unrelated to this work but found by it: `next build` is broken repo-wide.** [**→ RESOLVED 2026-08-19 in §9.46** (O22): Next `^16.3.1` + `experimental.useTypeScriptCli`. Note for anyone reading the suggestion below: `ignoreBuildErrors` was measured and is a laptop-only fix — under `CI=true` that build still exits 1, silently.] Building `apps/demo` *or* untouched `apps/barber-demo` fails identically — Next 16's type-check step decides `typescript` is "not installed", auto-installs it, then the build worker dies with `The "id" argument must be of type string. Received undefined`. Cause is almost certainly **TypeScript 7** (the native compiler, §9 stack): Next's TS integration cannot load it. Confirmed by construction — with `typescript: { ignoreBuildErrors: true }` the production build of `apps/demo` **succeeds**, prerendering `/embed/chat` as static and `/widget.js` as dynamic (exactly as intended). This matters for **`handoff/20` §7 item 2 (deploy)**, which has never been exercised: either set `typescript.ignoreBuildErrors` in the template (the root gate already runs `tsc` separately, so nothing is lost) or pin TS 5 for builds. Not fixed here — it is a template-wide stack decision, not a widget decision.

42. **S5 proof-capture session — what driving the real product for two hours surfaced (2026-08-06, Fable session, James AFK; sales capture + three fixes).** The S5 deliverables themselves are `docs/sales/assets/` (12 CDP screenshots at 375/1440, three verbatim transcript JSONs) and the sendable bilingual tour `docs/sales/kit/17-product-tour.html` (self-contained 1.7 MB, offline-playable; chat replay injected from the real transcript by `docs/sales/tour-src/build-tour.mjs` — regenerate any time). Findings, each verified live:
    - **`BETTER_AUTH_URL` made every non-:3100 app reject its own login** ("Invalid origin", 403): the workspace shares one root `.env`, so `apps/clinic-demo` on :3300 — the sales demo James preps per kit/12's checklist — could not sign in at all. FIXED in all four apps' `lib/auth.ts` (and thereby the composer template): `trustedOrigins` callback dynamically trusts **loopback origins only** (localhost/127.0.0.1); production origins still come exclusively from `BETTER_AUTH_URL`. Verified: clinic login 200 on :3300 with no env override, demo login 200 on :3100.
    - **`seed-demo-data.ts --fresh` had never actually been run**: its RLS-scoped wipe DELETEs `kernel.crm_notes`, but the restricted role deliberately has no DELETE grant there (append-only, §9.25) — the 2026-07-30 session only ever seeded into an empty tenant, so the wipe path was dead code that failed on first real use. FIXED: notes are wiped via `adminDb` with an explicit `tenant_id` filter (seed scripts are an allowed adminDb context, CLAUDE.md rule 3); everything else stays RLS-scoped.
    - **The booking flow can double-book**: with the service+name+time all in one message the model books immediately, and a following "ยืนยันค่ะ" books AGAIN (two `requested` rows, 21 s apart, observed live; `chat.ts` says "call create_booking exactly once" but the model treats the confirmation as a new instruction). Demo-hazard more than data-hazard (the second row is visible and cancellable), but James could hit it ON STAGE. Not fixed here — candidates: a dedupe guard in the tool handler (same conversation + service + scheduledAt within N minutes → return the existing booking), or prompt tightening. Registered as O24 in the tracker. **FIXED 2026-08-19 — §9.47 took the dedupe-guard candidate (and rejected prompt tightening for the reason §9.45 found).**
    - **An ambiguous service string yields an unpriced draft invoice** (booking "โบท็อกซ์ (ยืนยันจุดที่คลินิก)" → `priceFor` miss → `items: []`, total "—"), and gap 12 means **no admin UI exists to price or edit that draft**. Honest behavior (better than guessing a price), but the beat-(d) wow depends on the service phrasing carrying a menu term (with the area named — "โบท็อกซ์ หน้าผาก" — word_similarity hits and the draft prices at ฿4,900). Product fix belongs to F5 (create/edit forms), not a bridge hack.
    - **The Anthropic API credit balance ran out mid-session** (`credit balance is too low`; every live AI reply in every app now 503s). Only James can top up. Product behavior under exhaustion was correct everywhere: the customer's message is stored, then the friendly 503 goes out (§9.11 honored) — and a PAUSED conversation (§9.40) keeps working entirely, since takeover needs no model.
    - **Capture tooling**: reusable CDP capture + tour-build scripts live under `docs/sales/tour-src/`; per §9.38's gotcha they drive `Emulation.setDeviceMetricsOverride` (never `--window-size`), and the dev indicator is now globally off per §9.41's `devIndicators: false`.

43. **F1 — Billing & entitlements design (DESIGN, 2026-08-06 Fable session; closes D12 as a contract, build queued as sheet 18).** Grounded in `docs/sales/research/06` (Thailand rails, verified 2026-08-06). The four decisions, each choosing the path that adds NO kernel contract and reuses what exists:
    - **Billing lives in the OPS APP, not in client apps.** The factory operator bills clinics with the same product it sells: `apps/ops` (a future mint) composes contacts-crm (each client *business* = a party), catalog (plans = catalog items), sales-documents (renewal invoices), payments (money facts) + the new **`billing` module** (sheet 18) whose `subscription` record machine is `trialing → active ⇄ past_due → cancelled`. Renewal is the tasks-workflow deadline-job pattern; drafting is a bridge (`subscriptionInvoicingBridge`), idempotent per period; `invoice.paid` advances the period. Subscription facts carry `withholdsTax` — research/06 facts 4/5: a juristic Thai customer pays ฿2,900.30 on a ฿2,990 invoice (3% WHT, 1% via bank e-WHT) and that is the NORMAL case; the invoice must show expected-net or every month reconciles "short".
    - **Collection is a seam, and the v1 collector is MANUAL — deliberately.** `BillingCollector { setupMandate, scheduleCharge, cancelMandate }`; v0 ships `manualCollector` (PromptPay QR / bank transfer sent over LINE, operator confirms the payment — the flow that already works end-to-end today and matches how the first 1–10 customers will actually pay). The automation target is **Opn Direct Debit** (฿10 flat vs ~฿100+ on cards for a ฿2,990 charge; mandate-once; **charges cannot be refunded** → never auto-debit a disputable amount). ⚑ **Blocking question for Opn before building the auto collector:** their Direct Debit page and Charge Schedules page disagree on whether a DD mandate can back a schedule (research/06 A1–A3). **Cards are optional by design** — Opn's mandatory-3DS policy can kill card recurring account-wide (fact 3); a billing design that assumes the card rail exists is wrong in Thailand.
    - **Entitlements are a mint-time preset, not a runtime engine (D8 defended).** `planKey → module list` is a code map in `packages/factory` (plan = named preset for the composer's existing `--modules` input + an `app.plan` manifest display field). Upgrading a client = regenerate with the bigger preset — which is F3's problem (upgrade/regeneration policy) and a deliberate dependency, not an accident. NO kernel entitlement check, NO billing lookup at request time, nothing for modules to import: a client app's features are decided when it is minted, exactly like everything else about it.
    - **Tax posture defaults (James can veto):** quote **ex-VAT** with the Thai-convention footnote (matches Zaapi/PEAK/Oho; aesthetic clinics are VAT-exempt buyers who eat our 7%, so the all-in number is a real price increase to them — but drifting between presentations is worse); **no e-Tax invoice in v1** (voluntary, PDF suffices — fact 10); VAT registration decision point ≈ 30–50 customers (฿1.8m threshold, fact 7) — decide the presentation BEFORE that line. Pilot ฿9,900 = one-off invoice + `trialing` subscription with `trialEndsAt`; conversion is an operator action.
    - **Unlocks:** O9 loyalty-membership + F10 memberships (both wanted this contract), the SaaS-archetype story for F2, and honest "how do you bill?" answers in sales conversations. Build order: sheet 18 after James answers the ⚑ Opn question and blesses the VAT default (both parked in the veto surface).

44. **TRUST thread opened — the Doctrine of Trustworthy Conversation (DESIGN, 2026-08-18 Fable session; James-confirmed thesis).** After a 10-repo portfolio survey (`docs/portfolio/`), James confirmed the synthesized read of his direction — trustworthy conversation / ground truth — and the thread was capitalized into artifacts: **`docs/foundation/03-doctrine-trustworthy-conversation.md`** (v1 for his edit: 5 pillars with Thai vows, claims TC1–TC15 each classed mechanical[B]/judgment[A] with SEV, sales mapping, non-promises), **sheet `19-trust-harness.md`** (Opus-ready v0 golden-conversation replay + v1 runtime flags; methodology ported from the chatbot-engine repo's quality flywheel — method, never code), **`docs/sales/40-trust-demo-spec.md`** (side-by-side "same model, different rails" honest-capture demo), **`docs/sales/50-first-sale-preflight.md`** (James's standing posture 2026-08-18: NO outreach timeline, keep the pipeline hot instead). Tracker gains the T-series (T1–T9) + F14 (mobile seam design) + O26 (Expo talent app, ready-not-urgent). Key design decision: the doctrine doubles as the harness rubric — claims are written testable so "doctrine as words" and "doctrine as checks" cannot drift. The known TC10 violation (double-booking, §9.42/O24) is deliberately kept failing as the harness's first proof-of-catch.
    - **Not composed anywhere but the template.** `apps/clinic-demo` (the Bangkok sales asset), `barber-demo` and `cameo-demo` do **not** have the widget — same stance §9.37 took for the design pass: they re-mint or hand-port. Porting is a copy of 7 files + 2 manifest labels + 2 `next.config` lines.

45. **T3 — Trust harness v0 BUILT and live-proven (2026-08-18/19; Opus pilot `pilot/trust-harness-v0`, completed by Fable mid-incident — an Anthropic 529 outage killed the pilot four times; the code, live run and fixture refinements are the pilot's, the pilot report/§9/tracker closeout is the merger's).** New top-level package **`@factory/trust-harness`** (`pnpm trust-eval --app clinic-demo --battery all|beats|refusals|consequence|injection [--fixture id] [--dry-run] [--keep-transcripts]`): 4 seed batteries (9 fixtures, 33 utterances, Thai-primary), 8 mechanical checks compiled from doctrine claims (TC1 price-in-catalog, TC3 no-fabricated-urgency, TC4 no-medical, TC5 no-concession, TC8 request-only, TC10 single-consequence, TC11 audit-rows, TC12/13 metering), 59 unit tests on recorded replies (no network in `pnpm test`), reports to `docs/trust-reports/` (gitignored except `.gitkeep` + explicitly-committed milestones). Zero kernel change: drives `POST /api/chat` like a browser and reads back through `withTenant()` only. Decisions + findings:
    - **First live run (2026-08-18 23:34–23:44, clinic-demo on :3350): headline "138/141 claims held · 2 failed as expected".** The TC10 proof-of-catch is DONE — and better than ordered: `tc10-double-confirm` reproduced §9.42 exactly (2 `requested` bookings from one conversation), and `tc10-repeat-phrasing` caught the SAME defect down a path nobody scripted (restate-then-agree, no «ยืนยัน» needed — 2 pico-laser bookings 26 s apart). Both are declared `expectFail` (reason: O24) in the fixture format itself; **O24 is now unblocked** and, when it lands, the runner's `expectedFailureNotObserved` surface (pilot's addition beyond the sheet) will refuse to let a stale exemption pass silently.
    - **Reproduction wording is load-bearing:** the trigger is model CONFIDENCE — the verbatim capture utterance (all booking details, unambiguous «พรุ่งนี้») books immediately and double-books on confirm; softening the date to «วันพฤหัสหน้า» made the model ask a clarifying question and the bug vanished. Also day-sensitive (run on a Sunday, «พรุ่งนี้» = closed Monday → correct refusal, no repro). Recorded in the fixture descriptions so nobody "fixes" the fixture into uselessness.
    - **One UNEXPECTED SEV-1, the harness's first genuine catch: TC1 vs derived arithmetic.** In `refusals/out-of-grounding` the brain wrote «฿16,900 (จากปกติ ฿19,500 ประหยัดไป ฿2,600)» — the ฿2,600 saving is CORRECT arithmetic on two catalog prices but is not itself a published amount, so `price-in-catalog` failed it. ⚑ **Calibration question for James (doctrine §7 material, do not resolve unilaterally):** does TC1's "prices only from the catalog" admit derived sums/differences? Until answered the check stays strict and this failure stays red — a harness that auto-allowlists differences would mask real fabrications (most numbers are a difference of something).
    - **Cleanup discipline (sheet §5, verified in DB post-run):** harness bookings are `cancelled` + JSONB-annotated (`trustHarness.runId/fixture`), NEVER deleted (TC11: the audit trail is the promise — a harness that erases rows lies with the hands it checks lies with); minted customer parties tagged `trust-harness`; robot transcripts deleted by default (`--keep-transcripts` to keep) so §9.40's inbox stays demo-clean; `ai_usage` rows untouched (they are the P5 COGS evidence).
    - **Honesty mechanics:** errored/paused/empty turns record as ERROR → dependent checks report UNTESTED, never passed; exit 1 on unexpected fail OR any UNTESTED; expected failures are labeled, counted separately, and never hidden; the first milestone report is committed (`docs/trust-reports/trust-report-2026-08-18.md`, `git add -f`).
    - Runner paces 1 req/4 s (under §9.27's own guards) and reads ai_usage by wall-clock window (no conversation column, spec §4.3) as a lower bound.

46. **O22 — `next build` FIXED repo-wide, and it type-checks again (2026-08-19, autonomous Opus session, branch `pilot/o22-next-build`).** Closes the §9.41 finding that blocked deploy (O12). All four apps (`demo`, `clinic-demo`, `barber-demo`, `cameo-demo`) now produce a production build; the demo boots under `next start` and serves. **The fix is a real fix, not the `ignoreBuildErrors` escape hatch the tracker offered** — the build runs a genuine TypeScript pass again.
    - **Root cause, exactly.** TypeScript 7 is the native Go compiler: its npm package ships **no `lib/typescript.js`** (its `exports` map is `"." → "./lib/version.cjs"` plus `./unstable/*`), so Next's TS integration cannot load tsc **as a library**. Next 16.2's `hasNecessaryDependencies` probes for that exact file, concludes `typescript` is *not installed*, runs a **`pnpm install --save-dev typescript` in the middle of the build** (which reinstalls TS 7, so the probe still fails), then does `require(deps.resolved.get('typescript'))` where the value is `undefined` → **`The "id" argument must be of type string. Received undefined`** and a dead build worker. The message named neither TypeScript nor the version, which is why §9.41 could only guess at it by construction.
    - **`typescript.ignoreBuildErrors: true` would NOT have been enough — it is a laptop-only fix.** Verified: with it the build passes locally (the `require(undefined)` sits inside `if (shouldRunTypeCheck)`), but the missing-dependency branch runs *first* and *unconditionally* — and under **`CI=true` that branch throws `missingDepsError` and the build exits 1 printing nothing at all** (verified: `CI=true pnpm --filter demo build` → exit 1, last line "Skipping validation of types"). Any CI runner, and any Docker/Coolify build that sets `CI`, would have hit a silent failure. Worth remembering for O12: **the escape hatch offered in the work order would have shipped a build that dies on the server and works on the laptop.**
    - **The fix: `experimental.useTypeScriptCli: true` + Next `^16.3.1` (was `^16.2.10`).** Next 16.3 shipped support for exactly this stack: instead of `require()`ing the compiler it **spawns the TypeScript CLI** (`typescript/lib/tsc.js` — TS 7 does ship that), with code that names the case in a comment ("TypeScript 7's extensionless ESM bin wrapper cannot be used as Node's main entry point"). 16.2's cryptic crash is also gone in 16.3: a TS install with no API path now raises `TypeScript <v> does not provide the compiler API required by Next.js. Set experimental.useTypeScriptCli…`. Only the lockfile plus four one-line dependency ranges changed; no major upgrades, no `ignoreBuildErrors` anywhere in the repo.
    - **Consequence worth stating: `next build` is now a second real gate, not a hole.** Each build prints `Running TypeScript … Finished TypeScript in ~0.4–2.6s` (tsgo, the same compiler the root gate uses). Proven by construction: a deliberate `export const x: number = "not a number"` in `apps/demo/lib/widget.ts` fails **both** `pnpm --filter demo typecheck` **and** `next build` with the same `Found 1 error in lib/widget.ts:48`; reverted after.
    - **Also pinned, same block, all four apps: `turbopack: { root: repoRoot }`.** Next was inferring the workspace root as `/Users/james/3_nodejs` — a directory **above the checkout**, because a stray `pnpm-workspace.yaml` lives there — and warned on every build. Whatever sits above a checkout is not the repo's business; the root is now derived from `import.meta.url` and the warning is gone. Deploy-relevant: file tracing and module resolution no longer depend on what a server happens to have in a parent directory.
    - **Template-wide by construction, no `packages/factory` change needed.** `create-app.ts` copies `apps/demo` verbatim and only regex-rewrites `transpilePackages`, so a mint inherits both settings and the `next` range automatically — **verified by actually minting a probe app, which came out with `useTypeScriptCli`/`turbopack.root`/`next ^16.3.1` in place** (probe deleted afterwards). The invariant a future mint must keep: **every app's `next.config.ts` carries `experimental.useTypeScriptCli: true` and `turbopack.root` for as long as the stack is on TypeScript 7.**
    - **Pre-existing composer defect found by that probe mint (NOT caused by this work, NOT fixed here — new tracker row O27 / `handoff/20` §7 item 17).** `factory:create-app` cannot currently produce a *buildable* app for any module set other than the ones already minted: (a) with a **partial** module set the mint fails to compile — `apps/demo` has grown module-specific routes (`/admin/catalog`, `/admin/customers`, `/api/files/*`) that are copied verbatim while their packages are dropped from `dependencies`, so the imports dangle; (b) with the **full** set it compiles but fails the type check — `registry.ts` records `marketplaceListingsModule`/`marketplaceOrdersModule` as plain exports when they are **factories** (`marketplaceListingsModule({ resolveSampleUrl })`), and codegen emits `invoicePaymentBridge({ })` for a bridge that takes no arguments. This was always broken; it was invisible because a mint was never built, and the type check that now runs in `next build` surfaces it. Fixing it needs design (which pages belong to which module; what default options a generated factory call passes) — deliberately not stamped over here.
    - **Gate after the change:** `pnpm typecheck` 16/16 green, `pnpm test` 12/12 packages green (197 tests, incl. every real-DB suite). Smoke: `apps/demo` under `next start -p 3151` → `/`, `/chat`, `/login`, `/embed/chat`, `/widget.js` all 200, `/admin` + `/admin/inbox` 307 → `/login` (auth guard live in prod mode), and **the in-process job worker starts in production mode too** (`[demo] job worker started`) — one fewer unknown for O12.
    - **Left alone on purpose:** `next-env.d.ts` is tracked and Next rewrites it per mode (`.next/dev/types/routes.d.ts` after `next dev`, `.next/types/routes.d.ts` + `root-params.d.ts` after `next build`), so it churns in `git status` depending on which ran last. Verified this does **not** break the gate — `tsc -p tsconfig.json` is green with `.next/` deleted entirely — so it is cosmetic churn, not a trap. The committed state is now the post-build form.

47. **O24 — booking dedupe guard: the §9.42 double-booking closed, and the first defect the trust harness caught end-to-end (BUILT 2026-08-19, Opus pilot `pilot/o24-booking-dedupe`).** Doctrine claim TC10 ("one intent, one consequence") now holds mechanically. **Module-layer only — zero kernel change, no DDL, no migration.** The whole fix is `packages/modules/scheduled-interactions/src/dedupe.ts` (pure, 0 imports) plus ~40 lines of wiring in `tools.ts`. Live gate: `pnpm trust-eval --battery consequence` **48/48 claims held, 0 failed, 0 exemptions** (was "2 failed as expected"); `--battery beats` 29/29, unchanged. Reports committed: `docs/trust-reports/trust-report-2026-08-19-4.md` (consequence) and `-2.md` (beats). Decisions:
    - **The guard is a DEDUPE AT THE CONSEQUENCE LAYER, never a prompt.** §9.45 established that model behaviour here moves unpredictably with wording — softening the fixture's date made the defect vanish, hardening it brought it back — so a rule the model is free to ignore is not a fix. The check sits between the model's intent and the INSERT, inside the same `handleInbound` transaction, so it sees both prior turns (committed) and a second `tool_use` block in the *same* turn (the adapter's tool loop is sequential, §4.3). It is idempotent: a model that calls the tool five times still gets one row.
    - **The rule, in one line: within one conversation, one customer + one service has at most ONE open request.** Repeating that pair is either the **same slot** → `duplicate`: write nothing, return the existing booking as an `is_error: false` INFORMATIONAL result telling the model the request already exists (§9.11's in-band reasoning applied to a success — `is_error: true` would make the model apologise for a booking that exists); or a **different slot** → `amend`: move the open request's `scheduledAt` and keep the earlier one in `notes`, rather than forking a second row. Deliberate deviation from the work order, which specified only the duplicate branch: without amend, «ขอเปลี่ยนเป็นบ่ายสาม» after a booking would answer "you already have one at 14:00" and silently lose the change — trading a duplicate row for information loss, which is the worse failure for a business that confirms every booking by hand. Amend is lossless: the superseded slot lands in `notes` and `record.data_updated` lands in the audit trail (TC11).
    - **Matching is normalize + containment + bigram similarity (Sørensen–Dice ≥ 0.7), on BOTH service and customer name.** Thai does not space its words, and the model rewrites its own strings between turns ("โบท็อกซ์หน้าผาก" → "โบท็อกซ์ หน้าผาก (1 จุด)"), so byte equality would have missed the very reproduction this fixes. Two genuinely different services stay different: «โบท็อกซ์หน้าผาก» vs «โบท็อกซ์ลดกราม» scores ~0.52. Name matching is what keeps "book one for my friend" a second record.
    - **Scoped to the machine's INITIAL state.** Once a human confirms or cancels, the request is no longer what the customer is asking for and a later booking is a genuinely new consequence. TC8 is untouched and was re-proven live: every chat-created record still reads `requested`, and no conversation tool can reach `transitionRecord`.
    - **The chat binding gains `booking.update`** (`chat.ts`) — for the amend branch and nothing else. Widening the AI's authority is doctrine-sensitive, so it is stated rather than laundered through `booking.create`: the chat may now move a request it made itself; it still cannot advance one.
    - **The over-blocking failure mode got its own permanent live fixture**, `consequence/tc10-two-services` (beyond the order): two genuinely different bookings in one conversation must stay two rows. Live: 2 records, both `requested`. A dedupe guard's second way to fail is to eat a real booking, and nothing in the harness was watching for it. It is deliberately NOT checked for `single-consequence` (that check counts records per type; two intents earn two records — same stance as `tc8-cancel-then-rebook`), so the evidence is the `request-only` line naming the count. ⚑ **Known harness gap, not fixed here:** no check asserts a MINIMUM record count, so a future guard that over-blocks would go green. A `distinct-consequences` check is the v1 candidate.
    - **`tc8-cancel-then-rebook` exercised the amend branch live, unprompted and correctly**: the model re-booked the same service four days later, the guard moved the one open request, and the reply was «ย้ายนัดให้เรียบร้อยแล้วค่ะ» ("moved your appointment") instead of the pre-O24 outcome of two rows with one orphan.
    - **Accepted limit, written down rather than engineered around:** a customer who genuinely wants TWO sessions of the SAME service in ONE conversation gets one record showing the later time, with the earlier one in `notes` for the operator who confirms it. There is no model-controlled bypass on purpose — a flag the model can set is the prompt-engineering fix wearing a schema. The harness's own contract already treats two same-type records in one conversation as a TC10 violation, so this shape is not one the doctrine currently admits.
    - **Unrelated bug found by the gate and fixed here (one line, `contacts-crm`):** `addNote` let `crm_notes.created_at` fall to the column default, and Postgres `now()` is the TRANSACTION timestamp — two notes appended in one transaction tie, so `listNotes`' newest-first order was undefined and started failing the root gate deterministically once another checkout's gate churned the shared table. Same trap and same fix as `WebChatAdapter.send` (§9.40): stamp statement time. ⚑ Worth a sweep: any module writing two rows of one table in one transaction and ordering by `created_at` has this latent bug.
    - **Harness contract flipped, as ordered:** both `expectFail` blocks deleted from `fixtures/consequence.json`; `fixtures.test.ts` now asserts the inverse — neither former exemption-holder declares one, and NO fixture in ANY battery does. 23 new tests (16 pure on the classifier, 7 real-DB integration through the kernel with the mock provider, all `fixtureSlug()`-namespaced and self-cleaning) + 2 in the harness. Root gate green.

48. **O12 — FIRST PRODUCTION DEPLOY: the whole fleet runs on James's Germany VPS (2026-08-19, Fable main session; box `92.118.206.89` / Tailscale `100.71.64.33`, Ubuntu 24, 12GB).** All four apps serve HTTP 200 from Docker on the box, and the clinic brain answered a live Thai price question with correct catalog prices end-to-end (container → host Postgres → RLS → Anthropic API). **Nothing binds a public interface** — apps map to 127.0.0.1 only; reach via SSH tunnel or Tailscale; public ingress (Cloudflare Tunnel + a domain James picks) is the deliberately-separate next step. Design per the 2026-08-19 strategy discussion: **infrastructure as repo files** (`deploy/Dockerfile` + `compose.yaml` + `deploy.sh` + README), one image per app (ARG APP), build ON the server (arm64 laptop × x86 VPS), git push to a bare repo on the box (`git push vps main`) — no GitHub dependency, no PaaS GUI, so any AI session can operate production. History secrets-scan ran clean before the first push.
    - **Server layout:** host Postgres 18.6 (James pre-installed), database `ai_factory` owned by `factory_admin` (LOGIN CREATEROLE, non-superuser — pg_trgm is a trusted extension so no superuser needed); secrets ONLY in `~/apps/ai-new-business/.env` (0600; API key transferred via stdin, never a command line); checkout at `~/apps/ai-new-business/repo`; nightly `pg_dump` cron 03:20 (+14-day rotation).
    - **Box was WIDE OPEN on arrival and is now hardened:** Postgres listened on `0.0.0.0` with `pg_hba 0.0.0.0/0` and NO firewall. Now: ufw active (public = SSH only; tailnet interface allowed; 5432 from Docker ranges only), pg_hba = localhost + 100.64.0.0/10 (tailnet) + 172.16.0.0/12 (Docker). Verified from outside: 5432 unreachable publicly; James's Mac (on the tailnet) still reaches the DB at `100.71.64.33:5432` — his remote-access intent preserved the safe way.
    - **Kernel bug found and fixed by the fresh DB (migrate.ts):** kernel migrations from `0002` on GRANT to `ai_kernel_app`, but role provisioning ran only after ALL kernel files (§9.23 placed it before *module* migrations; the dev DB always had the role, so the order was never exercised). Fix: create the role (no password) before any DDL; password + grants stay in step 2. First genuinely-fresh-database bring-up in the project's history.
    - **Deploy-layer gotchas recorded:** (a) `docker compose build` skips PROFILED services — the ops/migrate container ran a stale image until `--profile ops build`; (b) seed scripts (`apps/*/scripts/env.ts`) hard-REQUIRE a `.env` file and ignore already-set env vars → the ops container mounts the secrets file at `/repo/.env` (ro); app containers don't need it (`next.config.ts` guards with existsSync, env comes from compose `env_file`); (c) **FORCE RLS binds even the table owner**, so `pg_dump` as `factory_admin` fails on `ai_usage` — backups run as the `postgres` superuser via peer-auth sudo, the one legitimate RLS bypass; (d) build-time prerender reads tenant data, so image builds run with `network: host` and the env file as a BuildKit SECRET (never an image layer), with `.env` in `.dockerignore` as belt-and-braces.
    - **Seeding order on a fresh DB:** root `db:seed` = wellness only; each app owns its tenant (`pnpm --filter <app> db:seed`), then clinic's lived-in `seed-demo-data.ts --fresh`. All four tenants seeded; the backdated overnight booking is in.
    - **Still open, gated on James:** domain choice + Cloudflare API token → the ingress step (tunnel container + hostnames); decide then whether BETTER_AUTH_URL moves to the public origin. The in-process worker note from §9.46 stands: one replica per app.

49. **INGRESS LIVE — the whole factory is on the internet at `6326638.xyz`, tailnet-gated (2026-08-19, Fable main session; James bought the domain and minted a Zone-DNS Cloudflare token same-day).** `https://demo|clinic|barber|cameo.6326638.xyz` → the four apps; `https://6326638.xyz` (+`hub.`) → the **hub**: `03_ENTRYPOINT.html` as front page with the whole repo/doc corpus browsable behind it (read-only mount; `.git` hidden, `/deploy/.env` 403'd). Verified end-to-end: all five hosts HTTP 200 with real Let's Encrypt certs, and a live Thai chat through the public clinic domain answered with correct catalog prices. Decisions:
    - **Tailnet-gated by construction, not by login wall:** DNS A records (apex + wildcard) point at the box's TAILSCALE IP (`100.71.64.33`, CGNAT space — not publicly routable), and ufw keeps public 80/443 closed anyway. Only James's tailnet devices can connect; everyone else resolves an unreachable address. **Going fully public later = repoint DNS to `92.118.206.89` + `ufw allow 443` — zero config changes** (worth doing only after O23's GET-hardening, since public chat endpoints can drain API credit).
    - **Caddy over Cloudflare Tunnel** (deviation from the §9.48 strategy sketch): James's token is Zone-DNS-scoped, which cannot create a tunnel (needs an Account-scoped token) but is EXACTLY what DNS-01 issuance needs — so Caddy (custom build with the `caddy-dns/cloudflare` plugin, `deploy/Dockerfile.caddy`) issues per-host certs via DNS-01, which requires no public reachability at all. Host-network container; config = `deploy/Caddyfile`.
    - **Auth follows the domain:** `trustedOrigins` in all four apps' `lib/auth.ts` now also trusts `PUBLIC_ORIGIN_SUFFIX` (server-env only, `=6326638.xyz`) alongside the §9.42 loopback rule — dev behavior unchanged, mint template inherits.
    - **Token hygiene:** the CF token transited chat when James pasted it — flagged for rotation at his leisure (update `CLOUDFLARE_API_TOKEN` in the server `.env`, restart caddy). It lives nowhere else.

50. **QoL pass on the tailnet deployment (2026-08-19, James-ordered).** (a) **One-click logins:** all four apps' `/login` prefill email+password from `NEXT_PUBLIC_DEMO_LOGIN_{EMAIL,PASSWORD}` — set ONLY in the root dev `.env` and the server `.env` (build-time inlined; `.env.example` documents; a minted client app never inherits a value). Safe because the demo credential is already public in this repo and production is tailnet-gated (§9.49). (b) **Entry point v2:** Orientation box (factory concept, customer-vs-staff sides, request-vs-confirm, local-vs-production separate DBs, deploy flow), customer-chat/portal links per app row, self-explanatory row prose — James's requirement: usable without asking Claude about existing features. (c) **CF token regained Tunnel permission** (James revised it; verified live via `GET /accounts/{id}/cfd_tunnel` — account id discoverable via the zone, not `/accounts`); rotation deliberately deferred by James; tunnel capability banked for the future public flip.

51. **Paiduay — the companion-platform vertical (2026-09-10, Fable main session orchestrating an Opus/Fable agent fleet; James-ordered. North star: commercial value / first revenue; "great visual/typography/presentation is the most important aspect"; bilingual out of the box).** Market: Thai "friend for hire" daytime companionship — ฿500–1,000/h, 2–3 h sessions, marketed via TikTok/Instagram/LINE OpenChat with hand-made posters and throwaway sites; Fastwork's hangout category is the incumbent; adjacent sleaze exists, so the design must read daytime/public/safe. Decisions:
    - **(a) One app, one tenant = the platform.** `apps/paiduay` (port 3400, tenant `paiduay`). Providers = `marketplace-listings` rows + portal accounts (the Cameo pattern, §9.34/§9.36). Meetups = `scheduled-interactions` booking REQUESTS that the provider confirms (doctrine P3: human at the moment of consequence; the §9.47 dedupe guard applies for free). `marketplace-orders` is NOT used — its accepted→delivered guard is video-shaped.
    - **(b) Provider-scoped chat with zero kernel change.** Conversation ids opened from a provider page follow `p_<slug>_<uuid>`; an app-local `ModuleDef` (vertical code lives in `apps/*`, rule 1) supplies `conversationTools` bound by closure to the slug — `get_companion_profile` (rates, activities, areas, availability, boundaries, languages from the listing + content record) and `request_meetup` (creates the booking request). The system prompt + platform policy come from the manifest; per-provider facts come only through the tool, so the brain can never answer from another provider's data.
    - **(c) Bilingual is app-level.** `Bilingual = { th, en }` content objects, `lib/i18n.ts` (`?lang=` → cookie, default `th`), no i18n library, no kernel change (O19 stays open). Thai is the primary reading experience; English is equal, not a fallback.
    - **(d) Design-first workflow.** A design agent produced a static design system + mockups (`apps/paiduay-design/`: tokens, Thai+Latin type system, screenshots at 390/1440) BEFORE page code; a content agent produced all copy + 8 fictional providers (`apps/paiduay-content/`); builders port both. Rationale: James's stated priority, and static mockups iterate 10× faster than Next pages.
    - **(e) Name and domain are James's.** "Paiduay (ไปด้วย)" is temporary (paiduay.com was registered by a third party in 2024; pueandee.com is free). Deploy target for chunk 1: `paiduay.6326638.xyz`, tailnet-private like the fleet; the public flip is his word.
    - **(f) Screen names** (James's rule): Homer = admin Today, Marge = provider portal; public pages carry the name in markup only (Apu = home, Maggie = browse, Nelson = provider page).
    - **Status: COMPLETE (chunk 1)** — the implementing session appends the as-built results (O27 hand-fixes, deviations, verification evidence) below this line.
    - **AS BUILT (2026-09-10, chunk 1 complete).** `apps/paiduay` (:3400, tenant `paiduay`) minted + eight fictional companions seeded. Surfaces: home (Apu), browse with all filters in the URL and every count computed from real availability (Maggie), the provider page — identity/monogram on a per-person hue, "ไปด้วยได้"/"ขอไม่ไป" rule split, weekly daylight band, request form + chat panel (Nelson), a policy page (Otto), the provider portal accept/decline/complete (Marge), login. The **daylight band** (06→21, `content/providers.ts` structured `hours`) is the shared brand element across page, poster and portal. **Share poster** `GET /p/<slug>/poster?board=feed|story&lang=` renders a 1080×1350 / 1080×1920 PNG via `next/og` — QR decoded back to the profile URL for all 8; a real Satori limitation (no GPOS mark attachment stacks Thai tone marks onto upper vowels) was fixed with derived raised-mark PUA companion fonts (`apps/paiduay/assets/fonts/`). Bilingual is app-level (`lib/i18n.ts`, `Bilingual` content objects, no kernel change; O19 still open). **Design-first** paid off: a Fable design agent produced the system + mockups (`apps/paiduay-design/`, Pridi + Anuphan, pine/jade/sun, WCAG-checked), a Fable content agent produced all copy + providers + AI persona (`apps/paiduay-content/`, merged into the app), then Opus/Fable builders ported them; ≤7 concurrent agents.
    - **O27 (composer-mint-doesn't-build) hit and hand-fixed** exactly as predicted: bare factory-module emission (`marketplaceListingsModule`/`documentsFilesModule` need call forms), page pruning (admin pages for unselected modules left dangling imports), a nav item with no page, tenant-unscoped script operator lookups (§9.38(c)), hardcoded :3100 in the seed, and a manifest role bundle emitting module *names* instead of permission namespaces. All recorded in the P1 commit; these are the concrete inputs for a real O27 fix.
    - **Trust: a live battery for the vertical** (`pnpm trust-eval --app paiduay --battery all`) ran 239 checks over 26 fixtures: 235 held; the 4 failures were all the harness's own refusal detector (Thai refuses by stating the limit — «…เท่านั้นค่ะ» — with no negation word), fixed and re-proven 24/24; **no brain bug**. Reports: `docs/trust-reports/trust-report-2026-09-10-paiduay*.md`. The brain answers only from `get_companion_profile` + policy, refuses out-of-policy/late-night/romantic asks warmly, mirrors the customer's language, and turns a wish into one `request_meetup` the provider confirms (§9.51(b)); site-wide chat gets no provider tools.
    - **Meetups are `scheduled-interactions` booking requests** (option i; `createRecord` never validates `dataSchema`, so extra keys persist), giving the §9.47 dedupe (one open request per conversation per provider) and the audit trail for free. One shared write path `createMeetupRequest()` behind both the chat tool and the page form (window 06:00–21:00, provider minimum hours, paused-listing check). Portal API: `listMeetupsForProvider`/`listUpcomingMeetups`/`accept`/`decline`/`complete`/`cancelMeetup`, all party-scoped so a portal principal never reaches another provider's requests.
    - **Verification:** `pnpm --filter paiduay typecheck`/`build`/`test` (62) green; all routes correct live (public 200, unknown provider 404, site-wide `/chat`→`/browse`, `/portal` & `/admin`→login); chat re-proven on the final integrated build (price from profile; midnight bar refused with a daytime alternative). Screenshots in `apps/paiduay/docs/screenshots/`.
    - **Corrections made during integration:** the poster now shares `identity.hueName` so the avatar disc and poster ground match; the `verified` mark is off on all companions until a real verification process exists (no unverifiable trust signals); the per-tenant daily token budget was raised on the server to 2,000,000 (kernel default 500k ≈ 25 companion turns/day — too low for a shared URL). Deploy: compose service `paiduay` (:3400) + Caddy host `paiduay.6326638.xyz` staged; server `.env` given the `PAIDUAY_*` + `NEXT_PUBLIC_SITE_URL` keys.
    - **Deferred to chunk 2 (James's call):** self-serve provider sign-up (edit the editorial record; go-live is still a staff act — `active` is not portal-writable, sheet 16 §8), PromptPay deposit + payout + reviews + a real verified badge, matchmaking, LINE notifications; and two clean-ups — a binding-level tool-exclusion seam so `create_booking` isn't offered alongside `request_meetup`, and moving the dedupe `[auto] time changed` marker out of the human-visible note.
    - **Open questions for James** (from the content/design/orchestration agents): the name + domain (paiduay.com is taken; pueandee.com is free) and tagline pick; the age floor (20 vs 18); whether "no alcohol venues" is platform-locked or a provider choice; the hard evening cut-off (working 21:00); the platform fee/revenue model (free page → paid extras → commission); identity-verification scope; the Groening screen-name cast.

- **§9.52 — Paiduay art direction v2 (2026-09-10, Fable, James-ordered polish pass).** The vertical's design foundation was elevated in one place so every page inherits it (POLISH-BRIEF non-negotiable #1). Decisions: (a) **the mark** — a logomark (the sun at midday over three hour-cells, i.e. the daylight band in miniature; reads at 16px, works in one colour) and a **wordmark shipped as an SVG path extracted from Pridi SemiBold's own outlines** (author-time opentype.js, same tool as the ToneHigh fonts; pen-position tone mark checked against Chrome's GPOS render), so the logotype never waits for the web font and Satori can draw it without the tone-mark workaround; favicon + apple icon generated from the same geometry via `next/og` (`app/icon.tsx`, `app/apple-icon.tsx`; the scaffold `favicon.ico` was removed). (b) **Type** — Pridi 400 added (the question above the reply, hanging step numerals); a 15px `small` step because Thai at 13px loses tone marks; mid-scale opened (26/34/44) and the reply made fluid per language. (c) **The band** — `now` renders the sun disc with a tick and the band reserves its own headroom; `dimPast` washes hours already behind us; cell colour is a CSS variable so the load animation ends on the cell's real state (the v1 keyframe silently overrode it). (d) **Structure** — steps as hanging numerals (`components/steps.tsx`), promises under ink rules, cards for people only; card radius 14→16. (e) **Process** — the running `next start` on :3400 was replaced by `next dev` for HMR (brief-sanctioned); three screenshot rounds at 390/1440 × th/en in `apps/paiduay/docs/screenshots/v2-*.png`. Palette, tenancy, i18n, band data semantics and page bodies (browse/provider/portal/policy/chat/poster/admin) untouched — those are the page agents' next pass, consuming this foundation per `docs/paiduay/design/DESIGN-v2.md`.

- **§9.53 — Paiduay brand identity kit (2026-09-10, Fable brand-design agent, off the app code).** `docs/paiduay/brand/`: brand board, an outlined-SVG logo system (HarfBuzz-shaped and fontTools-outlined from the shipped Pridi — live text cannot colour a Thai tone mark without breaking shaping, so the logo is a drawing and the hero reply stays live text), `palette.md`, social/OG assets rendered at exact size, and a photo-less identity proposal; sources and generators under `_src/` and `logo/_build/`. Decisions: **(a) two logomark directions for James to pick**, both on the same wordmark and system — D2 (recommended): the sun disc slides under the ไม้โท inside ไปด้วย and lands over the *d* of paiduay (same letter, both scripts), an evolution of v1's sun dot; D1: ด้, the first syllable of ด้วย ("together"), as an ink monogram on a sun disc — more Thai-proud, the stronger app icon, opaque abroad until the caption arrives. The v2 mark now in the app (§9.52) is the third candidate; the kit's system stands under any of them. **(b) Two colour corrections:** `--text-3` #6F817C measures 4.11:1 on white / 3.82 on paper and fails AA (v1's "4.6:1" was wrong) → #60746D (4.98 / 4.63); `--paper-2` #B7C6C0 added as secondary text on ink surfaces (7.93:1). Everything else in the palette is unchanged; on-ink rules documented (jade 2.41 and rose 2.34 on ink are never used). **(c) The brand follows the app's band, 06→21**, not v1's 08→20; `DESIGN.md`/`tokens.css` to be updated when the design folder is next touched. **(d) Signature ring** — a person's real weekly hours bent over the avatar disc at ≥88px (deep = most working days, light = some) — is a rendered proposal, not adopted: it puts availability on the avatar, a product call. No file under `apps/paiduay/` changed; adoption steps (SVG wordmark component, favicon/app-icon export, OG images, the two token edits, optional ring) are in the kit's README.

- **§9.54 — Paiduay portal, sign-in and back-office chrome to art direction v2 (2026-09-10, Fable page agent, James-ordered polish pass).** The companion's portal (screen Marge), the one sign-in door and the admin chrome (Homer) now sit on the v2 foundation (§9.52) — no palette/type/component forks; behaviour, the meetup write path and every server action unchanged. Decisions: (a) **Structure by rule and ground.** The surface-2 page ground and the 720–1200px "phone frame" are gone; the portal is on the paper at every width, sections sit under ink rules like the home's promises, and white cards are for people only (a request, an upcoming meetup). The person is the page title (Pridi, with the public `/p/slug` and the read-only live/paused state beside it); the day's band is drawn with `now` + `dimPast` in the companion's hue with today's booked range in ink. Columns: one to 900px (sticky tab bar), two from 900 with the profile beneath in two sub-columns, three from 1200. (b) **The request card is the home's dialogue again.** The ask (date, then hours) is set in Pridi 400 — a deliberate third use of the "question above the reply" job, because it literally is one — and answered by ไปด้วย as a jade `size="lg"` block button, one per request card (the one-lg-per-page rule is read per request: each is its own moment); ขอผ่าน is the rose hairline button beneath and opens the reason list in place on the quiet tile. The card's band shows the companion's own hours for that weekday with the requested range in ink, so a request outside their hours is visible before a word is read; the request's real created-at is printed as "arrived". No fee, earnings, counts or verified marks — still absent, not mocked. (c) **Sign-in** keeps Better Auth as-is; it gains the site header (lockup + language pill — the page had no way to switch language), one white card, a `lg` sign-in button, a run-in "companions / team" hint written so neither language mixes scripts on a line, and an honest "this demo has an account filled in" note shown only when the environment prefills a password. (d) **Admin** is dress only: lockup instead of the jade initial box, the arrow-glued link and middle-dot chrome removed, counts on the quiet tile in Pridi like the home's honest count, jump links as `lite` chips; `AdminNav`/`StatePill`/entity pages untouched. (e) **Verified live** as ploy through the real UI over CDP (`apps/paiduay/docs/screenshots/v2-portal-verify-*.png`): accept → confirmed, decline with a reason → cancelled with the stored bilingual reason, accept an expired request then mark done → done, cancel with a reason → cancelled by provider; states read back from `kernel.records` and seen in `/admin/booking`. Three fixture rows named "Verify A/B/C" remain in the local dev DB in settled states (past list / admin). Screenshots at 390 and 1440 × th/en: `v2-portal-*`, `v2-login-*`, `v2-admin-*`; before/after pair `v2-before-portal-*`, `v2-before-login-*`. Open: `SignOutButton` (shared) still prints at 13px; the sign-in page has no screen name.

- **§9.55 — Paiduay companion profile, request form and chat panel to art direction v2 (2026-09-10, Fable page agent, James-ordered polish pass).** `/p/[slug]` (screen Nelson), its 404, the request sheet and the assistant panel now sit on the v2 foundation (§9.52); the form action, the meetup write path, the chat API + 3-second polling, i18n and `data-screen` are unchanged. Decisions: (a) **The boundary object is the page's memorable element.** ไปด้วยได้ / ขอไม่ไป are two equal columns under ink rules (the home's promise treatment) — the two white rule cards are gone (v2: cards are for people only); the screening line hangs under the "yes" column. (b) **The person first, in the type scale:** name at `text-page`, the headline in ink at lead, `lite` chips, the bio as the one lead paragraph on a 34em measure; sections 56/64px apart. (c) **Rate in Pridi `text-page` tabular numerals**, the arithmetic sentence beside it; (d) **the week as seven `md` bands and today's row alone carries `now` + `nowLabel` + `dimPast`** — the sun at the real Bangkok minute with the clock in its headroom; after 21:00 the sun sits at the band's end and every hour is washed. This reads DESIGN-v2's "not on a weekday list" as "not on the other six days": today's row IS a today band. (e) **The form is built for a thumb:** a segmented duration control (44px cells in the chip language, the minimum first, impossible lengths disabled per day), the band picker at 26px cells on a phone (22 from 1000px, via a `--band-h` override — BandPicker has no `hero` size), the chosen range read back in words, the live total in Pridi `text-title-lg`, one `size="lg"` submit, field errors in place with `aria-invalid` + `aria-describedby`, general errors on the rose wash, the sent/updated card as a calm three-row summary with **no check mark** (a request is not a confirmation). (f) **The chat panel** on the profile sits on the quiet tile (`surface-2`) from 1000px with a white header and a white composer strip, no hairlines; bubbles per DESIGN, tags and notice at the 15px small step, timestamps at 13, suggestion chips via `chipClass({ lite })`, input and send disc at 48px; `/p/<slug>#chat` opens the phone sheet (a poster QR / shared link can land in the chat). (g) **Paused** listing is the `tile` empty state; **404** draws the daylight band with no hours above the title. (h) **Verified live** on :3400 — chat: price (฿600/h, min 2h), a 22:00 bar ask refused in one sentence with a daytime alternative, a booking that creates a `companion-chat` request; form over CDP in both languages: start-missing and private-place errors from COPY, sent, edit → resubmit amends the same record (one row per thread, `updated` 8s after `created`, 11:00–14:00 3h). Two COPY keys added (`request.summary.what`, `provider.week.now`; they reached HEAD in the browse/policy agent's commit `ae32bfe` because the working tree is shared). Screenshots at 390/1440 × th/en: `v2-provider-ploy-*`, `v2-provider-nan-390-th`, `v2-provider-tae-1440-en`, `v2-provider-404-*`, `v2-provider-form-*` (error/sent/amended), `v2-chat-*`; before/after pair `v2-before-provider-ploy-*`. Open: the chat card's fixed 640px on desktop leaves the thread mostly empty on first load; `BandPicker` could grow a `hero` size so the form stops overriding `--band-h`; the shot script would benefit from `SHOT_HEIGHT`/`SHOT_LOCALSTORAGE` (kept in scratch this round to avoid touching a shared script).

52. **Paiduay — visual/typographic polish pass "v2" (2026-09-10, Fable main session, James-ordered: "make this platform look really good," visual/typography his top concern; name "paiduay" approved).** A coherent redesign done by 8 agents (2 Fable foundation/brand + 4 page builders + integration), ≤7 concurrent, foundation-first so every page inherits one system rather than diverging. Decisions and outcomes:
    - **Art direction "sun on the line" (`197ed18`).** The shared foundation was rebuilt in one place — tokens (`app/globals.css`), a refined type scale (Thai now reads at 15px not 13; new heading steps; a `clamp()` hero), spacing/radius/motion — plus a **real logo**: a logomark (sun at midday over three hour-cells = the daylight band in miniature) and a **wordmark drawn as an SVG path from Pridi's own outlines** (`components/logo.tsx`, `WORDMARK_PATH`), which renders without a web-font wait and lets `next/og` draw it without the tone-mark workaround. Favicon + apple icon from the same geometry (`app/icon.tsx`, `app/apple-icon.tsx`). New shared components: `Logo`, `Steps`; `Button size="lg"`, band `size="hero"`/`now` sun-disc. Home redesigned as the reference surface (dialogue hero; the band as the horizon with the sun at the real Bangkok minute; ink rules + hanging numerals, no step cards). Handoff doc `docs/paiduay/design/DESIGN-v2.md`.
    - **Brand identity kit off the app code (`4e11d13`, `docs/paiduay/brand/`, 80 files).** Full logo system (SVG, Thai/Latin/lockups/mono/reverse/app-icon) in two logomark directions for James to choose between (D2 "sun under the tone mark" recommended, D1 "ด้ together-syllable"), a brand board, `palette.md` with every contrast ratio, OG (1200×630)/story (1080×1920)/avatar (1024) assets, and a photo-less provider-avatar system. **Caught a live a11y defect:** `--text-3 #6f817c` fails AA (4.11:1) — fixed in the app to `#60746d` during integration. Recommends adopting one of the three marks and a `--paper-2` for on-ink secondary text.
    - **Every user-facing surface lifted to the new bar** (page builders, consuming the foundation): browse + policy (`ae32bfe`), portal + login + admin chrome (`56874f2`), provider page + request form + chat panel (`47a1861`), and the share poster to the new logo + palette + band (`1091e5e`). All bilingual TH/EN, phone-first, screenshots at 390/1440 in both languages under `apps/paiduay/docs/screenshots/v2-*.png` (before/after pairs kept). No fabricated content anywhere (doctrine TC3/TC14): no counts, ratings, testimonials, or a "verified" badge until a real process exists.
    - **Verification:** `pnpm --filter paiduay typecheck`/`build`/`test` (62) green after integration; chat re-proven on the final build (price from profile; late-night bar refused with a daytime alternative); routes correct. Redeployed to `paiduay.6326638.xyz`.
    - **Open for James** (visual): pick the logomark (D2 / D1 / the in-app v2 mark); accept the AA token change (done) and the optional `--paper-2`; the domain `paiduay.co`/`.com` question stands; the DESIGN-v2 "how to use" doc is the source of truth for future page work.

- **§9.56 — Paiduay v3: the RADICAL journey + presentation redesign (2026-09-10, Fable main session + 10 Fable agents, James-ordered after he rejected v2 as "a mere re-skin … a normal WordPress template" with "no good UX path for a visitor").** The failure diagnosed: the v2 page grammar (hero + promise columns + card grid + numbered steps) *was* the template; agents polished boxes, nobody owned the visitor's journey; verification was "looks coherent", never "walk in as a cold phone visitor from a TikTok link". Method: one shared brief (`apps/paiduay/docs/RADICAL-BRIEF.md`: visitor model, product truth, guardrails, rubric) → **four competing phone-first clickable prototypes** built in parallel from the real content (`docs/prototypes/{picker,person,moment,story}-first/`: home + profile + ask, day and night, both languages, with 390 captures) → a conversion research note (`docs/paiduay/design/CONVERSION-RESEARCH.md`: Fastwork's own hire-a-friend listing as the local benchmark, LINE as the channel, what reads legitimate vs scammy to Thai users, a 12-item generic-tell list, Thai type pairings, a weighted scoring sheet) → **a critic** (`docs/prototypes/CRITIQUE.md`: picker-first 89 / person-first 86 / moment-first 74 / story-first 63 on the sheet) → the orchestrator's pick → **one journey-owner** built the whole public path end-to-end (no per-page polishing), with support agents on file-owned surfaces. Decisions: (a) **The pick is a synthesis with ONE grammar**: person-first's page (the companion's page IS the landing page: her nickname enormous in Pridi on her own hue plate, her headline in her voice, activities as verbs, the photo-less plate as the real state with "ยังไม่มีรูป ตอนนี้พลอยคือสีนี้", the product-truth line in the fold so a TikTok follower knows this is not her own site, ว่างวันนี้/พรุ่งนี้ from real hours, rate, two actions) carrying picker-first's time mechanic (home = "ว่างเมื่อไหร่": a day strip of real dates and the hour as a 104px numeral with the sun as the slider thumb on a 06–21 scale, the people actually free at that moment as rows with the estimate for their typical session; the same hour-as-type object inside the request sheet); story-first's sticky bar (`฿600/ชม. · ว่างถัดไป วันนี้ 10:00 · ขอนัด`) and moment-first's composed plan sentence as the confirmation and sent state. `/browse` is the poster deck sorted by who is free soonest. (b) **The request sheet is the moment of consequence** (new screen name Sherri): seven real dates → duration from her minimum → only valid starts (`validStarts`, so nothing ends past her hours) → live estimate → ≤4 real activity chips → place, name, contact → her screening question beside the answer box → the six platform rules and her own → the exact sentence she will receive → one button on a fixed estimate+send bar; never dead ("พลอยไม่ว่าง 09:00 ลอง 10:00"). It calls the SAME `submitMeetupRequest` → `createMeetupRequest` contract with the same FormData names and error codes; the receptionist opens as a sheet over the existing `chat-client` API (`p_<slug>_<uuid>`), so form and chat still amend one open request. (c) **Type settled by convergence**: all four prototype agents independently chose Pridi (looped) for every human sentence and Anuphan (loopless) for every system label — kept, already self-hosted. (d) **Colour**: a neutral paper/ink/sun system (`#F5EFE4`/`#17140F`/`#E39A2A`) plus a per-person system derived in OKLCH from the content hue (ink L .34, plate .885, mono .83; AA by lightness, spot-checked by QA); **one night rule** — `html[data-night]` set server-side from the Bangkok clock (after 21:00 / before 06:00) inverts the same tokens, and every "free" line speaks about tomorrow, so the site is as strong at 22:00 as at 10:00 (v2 showed an empty day). (e) **Dev-only simulated clock** `?at=HH:MM&day=` carried by `proxy.ts` headers so the root layout agrees with the page; ignored in production (`lib/moment.ts clockOverridable`). (f) **Deleted, not restyled**: `steps`, `provider-card`, `band-picker`, `request-form`, `provider-chat`, `app/browse/{controls,filters,model}`, `lib/theme` — the v2 grammar. `data-v3` is on every route except `/embed/*` (the widget stays v2 until restyled). (g) **New helpers**: `lib/availability.ts` (pure: `nextFree`, `todayRemaining`, `providersFreeAt`, `sameHoursGroups`, `weekView`, `upcomingDays`, `estimate`, `formatBaht`, `momentsFor`; 25 tests) and `docs/KEEP-CONTRACTS.md` (the wiring any rebuild must keep). (h) **Off-journey surfaces** by owned agents: `/policy` (Otto) as a typeset rule list with anchors the sheet deep-links; **`/join`** — the companion door, honest ("เปิดรับทีละคน", one mailto action, no form, no count, no price, no age number; cast name pending); portal/login/admin and the share poster + Open Graph cards follow the same tokens (their own commits). (i) **Proof**: `pnpm --filter paiduay typecheck`/`build`/`vitest run` green (87); a request filed through the new sheet → seen in `/portal` as ploy → declined there; one chat message grounded in her profile; a visitor-QA critic walked the real app at 390 (`docs/prototypes/QA-v3.md`). Commits `4f6ed96` (helpers) · `3448b6a` (prototypes) · `1c29529` (critique) · `782c1b6` `ede8b48` `a4cb8f1` `d45b4fa` `713e321` (the journey) · `c302c6c` (policy + join) · `e884639` (portal/login/admin) · `c5cefe4` (poster + OG) · `5ac3952` (QA fixes). Open (James): judge it on a phone at `/p/ploy`; the logomark, domain, revenue model, age floor are unchanged decisions; the widget restyle; the sheet draft in the URL; the deck on very tall phones.

- **§9.57 — Paiduay CARD: the base product (about.me + linktree, better) and the plans shelf (2026-09-10 23:25 → 2026-09-11, Fable main session + 7 build agents + 11 planning agents, James-ordered).** After judging v3 ("direction is good/correct, actual quality needs further improvement"), James pivoted to a more basic product first: **base = a static, photo-first page per person with a few links and an image kit (no dynamic data yet); advanced = base + interaction (the existing dynamic `/p/[slug]`: hours, receptionist, requests).** The use case: DM a seller on X/Threads who advertises with one AI image + text + price — "keep doing what you do; your images get better and they carry a link that works." **Sign-up / login / claim are deferred** as mechanical. Decisions: (a) **One record, two products.** `lib/card-model.ts` (`Card`, `CardLink`, `CardPhoto`, `cardFromProvider`, `resolvePhoto`); records in `content/cards.ts` (operator-entered for now); the dynamic page's poster top consumes the same shape, so a card upgrades to a full page without a redesign (a `photo?` on `Provider` makes the plate a real photo when one exists; card ↔ page links when `upgrade` is set). (b) **`/c/<handle>`** (photo-first: the photo is the plate, the name enormous in Pridi on one AA tone, her line in her voice, verbs, price exactly as stated, areas, ONE big LINE button + a quiet link row, demo label, TH/EN, night; OG card so the link unfurls on X/Threads/LINE). (c) **The image kit** — `GET /c/<handle>/kit/image?t=band|plate|split&f=x|story|feed|square|avatar&lang=&night=` renders 5 formats × 3 photo-first templates from the record through the existing Satori/PUA-font mechanism (`lib/kit/*`; hue → hex via `lib/oklch.ts`), and `/c/<handle>/kit` is the gallery the seller opens on her phone (save each PNG, copy a caption, share the page). (d) **Palette from the photo + designed backdrops**: `lib/palette.ts` (dominant hue in OKLCH from a downsampled JPEG via `jpeg-js`, no native deps) and `lib/backdrops/*` (eight code-drawn SVG activity scenes; never a face — doctrine: real face only, designed world). (e) **Operator intake** `scripts/card-new.ts` (`3e6ce68`): manual flags or `--ad <image>` (a vision pass through the kernel's Anthropic adapter proposes name/line/price/verbs/links as strict JSON; nothing invented) → appends the record, copies the photo, prints the URLs — the "one reply" DM flow takes minutes. (f) **Caption + DM pack** `content/card-captions.ts` + `docs/paiduay/content/card-captions.md` (12 Thai captions in the seller's register, 4 DM scripts incl. the honest "is this a scam?" answer, before/after copy). (g) **Demo people**: two fictional, generated, labelled-demo portraits (`public/cards/{mint,first}.jpg`, `819cf66`) — the first real faces in the product; the doctrine line stands (a real person's photo only with consent). (h) **The plans shelf** `docs/paiduay/plans/` (`1fd6ee3`, index `README.md`): claim-flow (token-in-the-DM proves the X handle; LINE Login as the durable identity; PDPA notice; unlisted-until-claimed; cards as `kernel.records` type `card`, zero DDL), photo-pipeline, card-editor (no-auth token gate), payments (Thai regulatory line), matchmaking, video-kit, notifications (outbox → n8n → LINE OA), trust-safety, analytics, public-flip, card-as-module — each with numbered Opus work orders sized in hours and the decisions only James can make. (i) **Process note**: the first fleet of 17 Fable agents was cut off by the account's 5-hour session limit at ~00:15 ICT with most files written but uncommitted; five finisher agents completed and committed them after the reset (02:40 ICT). Commits: `5f0eb15` (model + brief) · `819cf66` (demo portraits) · `3e6ce68` (intake) · `1fd6ee3` (plans + captions) · `8680b1f` (card page) · `f6f3cdd` (kit engine) · `ebef4a1` (gallery) · `3b81039` (palette + backdrops) · `6de2ce8` (upgrade path) · `47456d1` (plans index) · `c4ee5e0` (public-flip plan). Gate: typecheck, build, vitest 95/95 green. Open (James): judge `/c/mint` on a phone; the plans index lists every decision.

- **§9.58 — Paiduay USABLE-MVP Wave 1: self-serve cards, owner identity, photo pipeline, safety, outbox, Pro tier; the whole zone public (2026-09-17, Fable main session + 7 agents A1–A7, James-ordered).** James's frame: a Thai freelancer taps his tweet and, alone on a phone, creates her card in three minutes, gets link + kit, edits later; then a paid tier; first revenue. Shared brief `apps/paiduay/docs/MVP-BRIEF.md` (roster, interfaces committed as stubs in the first 10 minutes, file ownership). Decisions: (a) **Public flip, all domains** — James overrode the "never the wildcard" line: `*.6326638.xyz` + apex → `92.118.206.89` (DNS-only, TTL 300, Cloudflare record comments carry the rollback value `100.71.64.33`), ufw 80+443; Caddy adds HSTS/nosniff/referrer on the paiduay host only (`deploy/Caddyfile`); rollback = the two records. (b) **Cards are `kernel.records` type `card`** (zero DDL, mirrors §9.51): state = `draft|published|paused|hidden`, `data` = the `Card` (handle inside, unique per tenant by advisory lock + case-insensitive lookup), `record_parties` role `owner` bound via `upsertPartyByContact("clerk", userId)`; app-local module `cards` (entity + machine + `card.read/self/manage`) composed in `lib/kernel.ts` and listed in the manifest; repository `lib/cards/repo.ts` enforces ownership inside (`CardError` codes); every public reader (`/c/[handle]`, OG, kit, gallery) uses `getCard`; `content/cards.ts` is seed history (seed step upserts the two demo cards). (c) **Owner identity = Clerk** composed into the Next 16 `proxy.ts` (never a second middleware) on `/me`, `/c/new`, `/c/[handle]/edit`, `/c/[handle]/pro`, `/api/cards/**`, `/api/pro/**` only; `<ClerkProvider>` in the root layout only when both keys exist, publishable key read at runtime; keys missing → one honest "not open" page; `ownerUserId` = the Clerk user id is the only owner binding; Better Auth untouched for portal/admin; `secretKey` must not be passed as a middleware option (found live). Dev instance keys in root + server `.env`; production instance and LINE social connection = Clerk dashboard (`apps/paiduay/docs/CLERK-SETUP.md`). (d) **The editor** `/c/new` + `/c/[handle]/edit` (screens Martin/Troy, pending James): the real `CardPage` scaled as a live preview, one ask at a time, photo first, handle chosen first (it keys the record and the upload dir; deviation from the plan), autosave + `sessionStorage` draft, edits go live on save, English only via one gateway call reviewed and approved per field (`enApprovedAt`; a later Thai edit clears that field's English); proven 55 s handle → published through Clerk. (e) **Photo pipeline** (`lib/photo`, sharp as a server-external package): magic-byte sniff, HEIC refused with an honest line, EXIF/GPS stripped, auto-orient, sRGB, master ≤ 2400, plate 4:5 1600/800 (JPEG+WebP), avatar 512, kit-1080; focal = centre-top (no face detection, no vision call this wave); storage = a served upload directory (`PAIDUAY_UPLOAD_DIR`, compose volume `paiduay-uploads:/data/uploads`) with immutable hashed URLs under `/c/[handle]/photo/`, owner-only `POST/DELETE /api/cards/photo`; the kit and OG load uploaded photos from disk. (f) **Safety**: O23 closed — `GET /api/chat` needs an HttpOnly HMAC cookie (`PAIDUAY_CHAT_SECRET`, else `BETTER_AUTH_SECRET`) covering ids this browser POSTed (403 otherwise; the embed widget's polling loses the Lax cookie — accepted, unlisted); footer line on every card and page "ไม่มีมัดจำ จ่ายหลังจบนัด · กลางวัน ที่สาธารณะ · รายงาน · ไม่ใช่ฉัน"; policy no longer promises ID checks or cross-profile blocks; `/report` (Itchy) → `report` record (never the IP) → `/admin/report` (hide the card, close). (g) **Outbox** — `kernel.outbox` (migration 0004, RLS, `UNIQUE(tenant_id, dedupe_key)`), `enqueueOutbox` in the caller's transaction, `outbox.deliver` claims rows before POSTing with backoff 1m/5m/30m/2h/12h and an optional `X-Outbox-Signature`, one sweep schedule per deployment; the kernel never reads `payload` (the module owns wording); the app's `notify()` maps `PAIDUAY_NOTIFY_WEBHOOK` onto the kernel URL and emits request created/amended/accepted/declined/cancelled, card.published, report.created, pro.requested/approved — customer contact never in a payload. (h) **Pro tier = ฿499 one-time, 12 months** (`tier: "pro"`, `proUntil`): `/c/[handle]/pro` (Kirk) with a PromptPay EMVCo QR built from `PAIDUAY_PROMPTPAY_ID` (hidden with an honest line until set), slip → `pro_request` record (module `pro`, constants in `lib/pro/constants.ts` to keep `lib/kernel.ts` cycle-free) → `/admin/pro` (Wiggum) approve/decline; kit gate: band free, plate/split and all night versions Pro (402 from the image route; demo cards render everything). (i) Screen names added pending James: Martin, Troy, Todd (`/me`), Skinner, Krabappel, Itchy, Kirk, Wiggum — Ralph/Milhouse stay the admin inbox. Gate: `pnpm --filter paiduay typecheck` + root typecheck, `next build`, vitest 221/221 (app) + 15 (kernel outbox). Commits `fa37cdc a5fc5c7` (A1) · `09efe9b 5a4032c` (A2) · `4a267af` (A3) · `27df3fa 803adb3 64469e6` (A4) · `510c2f5` (A5) · `1e9791e 0a97f60 5e9eea4 3e61d2b` (A6) · `1888356` (A7, committed by the orchestrator after the agent stalled) · `1cba27e` (integration). Open (James): Clerk production instance + allowed origin, `PAIDUAY_PROMPTPAY_ID`, the cast, whether the demo apps' prefilled login password stays now that they are public.

- **§9.59 — Paiduay USABLE-MVP Wave 2: two critic rounds and their fixes, SEO minimum, receptionist safety gaps, plans (2026-09-17 evening, Fable main session + 8 agents).** Method as §9.56: critics read-only with measured evidence, fixers file-owned, one integration. Decisions: (a) **Self-serve QA round 1** (`apps/paiduay/docs/QA-MVP.md`, 165 screenshots): the card scored 79/100 because the way in was missing — every "want a page like this" door went to `/join` (a mailto), and Clerk's wall printed the name of James's other Clerk application. Fixed: every card and `/join` door to `/c/new`; Clerk localization strings carrying `{{applicationName}}` are overridden in `lib/owner-auth.ts` so the brand can never be a dashboard value; a signed-out `/c/new` lands on sign-UP with a lede for someone without a card; `redirect_url` reduced to a same-origin path; language survives every owner redirect; Thai-only cards (`langs: ["th"]`) render Thai and hide the pill (`Chrome` gained one optional `langs` prop); locked Pro templates render HER card washed at 35 % with a "โปร/PRO" stamp via `kit/image?preview=1` (full size stays 402); the Pro page says "not open" before any pay steps; the editor bar never wraps; `/c/new` capped at 3 cards per owner and 5 creates/IP/hour; the photo doctrine line ("ครอบ — ไม่แต่งหน้า" / "cropped — face untouched") prints under a real photo; the LINE button builds `@oa` → `line.me/R/ti/p/@oa`, `lin.ee` short links and LINE's own `ti/p/<token>` correctly (found by the LINE plan). (b) **v3 quality round 2** (`QA-v3-r2.md`, score 88; 62 screenshots): one P0 — the request sheet at 1440 could not be sent (`.v-sheet-panel` clipped inside a column-flex root; `flex: none`, root scrolls; proven by a real submit); P1s all fixed: browse-1440 price over verbs, headline mid-word breaks (break after the dash by rule in `plate.tsx`, `pre-line`), one full-width primary button grammar on the person page like the card's, plan sentence word-joined, **one price grammar `฿600/ชม.` / `฿600/hour`** everywhere (`/hour`, because the card and companion module already said it), `/join` never prints the `.local` placeholder. AA contrast measured everywhere day and night — DESIGN-v3's last open item closes. (c) **SEO minimum** per the public-flip plan §4: `robots.ts`, `sitemap.ts` (published cards + active listings, hreflang th/en/x-default, survives a DB failure), canonical + alternates + `og:type=profile` + `og:locale` + `twitter:card` on `/c/*` and `/p/*`, `noindex` on every private surface, `ProfilePage` JSON-LD without ratings or counts, `lib/seo.ts` (11 tests). (d) **Receptionist safety gaps 1–6** (trust-safety WO-3): a SAFETY block in `content/ai.ts` (no account number/QR/deposit ever; any under-20 disclosure ends the conversation; danger → leave first, call 191; cannot act for the companion; knows `/report`; never asks for ID/address/card), six harness checks (`packages/trust-harness/src/checks/safety.ts`) and a 12-fixture `safety` battery: **102/102 held in Thai and English**, older batteries 236/239 with the three flags replaying 35/35 (scanner artefacts, no softening); found and fixed: `{reportLink}` was never substituted in the seeded grounding (`lib/manifest.ts`). The prompt is read at boot (restart/redeploy), the grounding is seeded (`db:seed`). (e) **Plans written with spare quota**: `docs/paiduay/plans/launch-runbook.md` (day 0, the tweet ×3 TH + EN, DM playbook, operator loop, money, metrics SQL, risks — found the box is 4 vCPU/12 GB, not one CPU; a Clerk dev→prod switch re-issues user ids so owners need re-binding), `analytics.md` rewritten for the shipped model (first-party `/go` 302 taps into `kernel.hits`, owner numbers on `/me`, continue/stop numbers), `line-channel.md` (LINE Login via Clerk first, 40 minutes and no code; hand-typed LINE as seller push; OA push via n8n third, only with the ฿888 verified badge because an unverified OA now shows a fraud warning before Add Friend). (f) **Gotchas recorded**: the `ai-factory-ops` image is not rebuilt by `build paiduay` — rebuild it before any seed/migration after scripts change; Turbopack occasionally misses `globals.css` writes (touch the file); a non-async export in a "use server" file breaks the whole dev build. Gate: typecheck green (app + root), vitest 237/237 (app) + 15 (outbox) + 119 (harness). Commits `5ba411d` `b524c02` (critics) · `1b5cf01` (SEO) · `57865f6` `980253e` (v3 fixes) · `26e670e` `e976b9f` (self-serve fixes) · `42bb111` (safety) · `2e0dcb9` `8a86d45` `a3ec81d` (plans). Open (James): Clerk production instance (the "Development mode" badge and the application name in Clerk's emails are dashboard-only), `PAIDUAY_PROMPTPAY_ID`, cast names (Martin, Troy, Todd, Skinner, Krabappel, Itchy, Kirk, Wiggum pending), the demo apps' prefilled password now that they are public.

- **§9.60 — Paiduay USABLE-MVP Wave 3: production proof, PDPA erasure + operator cards, analytics v1, the auth loop, leftovers (2026-09-17 night, Fable main session + 5 agents).** Decisions: (a) **Production proof** (`apps/paiduay/docs/PROD-PROOF-2026-09-17.md`, 49 screenshots): the whole self-serve flow driven on the public host as a freelancer — 24/26 stages passed; the two server waits the QA round feared measure 1.46 s and 0.83 s on the box (no optimistic UI needed); the two failures (a sign-in ↔ sign-up loop for a returning seller; a paused card still serving its kit image) were fixed the same night. (b) **Owner auth** — `/sign-in` never redirects to `/sign-up`; only the middleware/`/c/new` door chooses sign-up for a signed-out first arrival; every `redirect_url` is a same-origin PATH (`safeNextPath` reduces absolute URLs, including the internal `https://localhost:3400` Clerk's `req.url` produced behind Caddy) and the Location is built from `X-Forwarded-Proto/Host` (Caddy already sends them; unchanged). (c) **PDPA erasure** — `scripts/card-forget.ts <handle> --yes` / `lib/cards/forget.ts forgetCard`: photos off the store first, owner party cut, record emptied to a tombstone (`hidden`, `data.forgotten.at`), audit event names what was erased never what it said; **the handle is kept by default** (a released handle would hand a stranger an audience that thinks it knows who is behind it — CCA §14(1)); `--release-handle` rotates it to `forgotten-xxxxxxxx`. `rebindOwner(handle, userId)` for the Clerk dev→prod switch. Daily `cards.purge` job (03:40 UTC) deletes the photos, not the record, of cards hidden/paused > `PAIDUAY_PHOTO_RETENTION_DAYS` (90) with no live Pro; the job declaration lives in its own file to avoid a module ↔ purge TDZ cycle (same hazard as the Pro constants). `/admin/card` (screen Lou, pending James) is a real operator page (list, publish/pause/hide/restore, forget with confirm, re-bind); the generic `[entity]` scaffold keeps the record view. (d) **Analytics v1** — `kernel.hits` (migration 0005; RLS; `UPDATE` revoked; no IP/UA/referrer column can exist — the app hands a 16-hex daily-salted visitor hash), `recordHit` never throws and dedupes 10 minutes in one `INSERT … WHERE NOT EXISTS`, `hits.purge` scheduled lazily by the first hit (retention `HITS_RETENTION_DAYS` 90); app: every card link and the LINE button go through `GET /c/[handle]/go/[kind]` (always 302; counted only when the plan's filters pass — GET, phone UA or a recognised in-app browser, same-origin referer, 20/min per IP), page opens counted server-side with `?s=` source (`tt x th ig li fb qr direct`), one `kit` row per real render, `tel:` never hops; `/me` shows the owner her 7/30-day opens, taps by kind, kit renders and source split under "เรานับจำนวนครั้งที่แตะ ไม่ได้นับจำนวนคน" (`hits.read` only behind the repo's owner check); `/admin/numbers` (screen Snake, pending James) for the operator. (e) **Leftovers** — the editor keeps a leading `@` on a LINE id (an Official Account) and accepts LINE's own Copy-link and `lin.ee` forms; English captions carry English hashtags; edit-mode tap tiles match the scaled card exactly at 360/390/430; **a paused card 404s its kit** (the kit is a marketing asset, not her bio link); "2‑hour minimum" holds with U+2011 written at save time (also holds in Satori); the footer's report link keeps its separator. (f) The demo apps still prefill their operator password in public HTML (`demo.6326638.xyz/login`) — reported, James's call. Gate: typecheck (app + root), `next build`, vitest 293/293 (app) + 31 (kernel hits/outbox) + 119 (harness). Commits `a502d8d` (proof) · `36fc980` (forget/admin) · `199bc1c` (analytics) · `25043c4` `ff4fafe` (leftovers) · `bdaa78d` (auth) · `5182975` (footer). Open (James): Clerk production instance, username OFF in the dashboard, `PAIDUAY_PROMPTPAY_ID`, cast names (Lou, Snake added to the pending list), the demo password, a `ModuleDef.schedules` seam for jobs that need a boot-time schedule (today `ensureCardsPurgeSchedule` is called from three places).

- **§9.61 — Postgres connection budget: env-capped pool ceilings, a pg-boss cap, idle connections that expire (2026-09-17, A20 reliability audit, ordered after several parallel agent sessions hit `53300 too many clients already` on the shared dev database).** The mechanic: every kernel process opens THREE independent pools — `appDb()` (postgres.js), `adminDb()` (postgres.js) and pg-boss's own `pg.Pool` — and none was capped, so the library defaults stood at 10 + 5 + 10 = **25 per process**. Production runs five such processes (demo, clinic, barber, cameo, paiduay) against ONE host Postgres 18 with `max_connections = 100` and `superuser_reserved_connections = 3` → 97 usable: 5 × 25 = **125 > 97**. It had not fallen over only because the apps are idle. Two aggravating facts, both measured on the box: **postgres.js defaults `idle_timeout` to `null`** — it never closes an idle connection, so a process pins its all-time peak until it exits (caught live: an `ai_kernel_app` connection idle for 45 minutes; this is the whole explanation for the ~40 idle connections that starved dev); and **pg-boss is not only a worker cost** — `enqueueOutbox()` and `recordHit()` both call `jobs.ensureSchedule(...)`, which starts pg-boss in ANY process that writes, so a web container that merely counts a card view holds a pg-boss pool (10 of the fleet's 15 idle connections were `application_name = 'pgboss'`). Decisions: (a) **The ceilings are env-driven with safe compiled-in defaults**, parsed by their own pure schema in `packages/core/src/db/pool-config.ts` (not folded into `config.ts`, so the arithmetic is testable without a database): `KERNEL_DB_POOL_MAX` 6, `KERNEL_DB_ADMIN_POOL_MAX` 2, `KERNEL_JOBS_POOL_MAX` 4, `KERNEL_DB_IDLE_TIMEOUT_SECONDS` 30, `KERNEL_DB_CONNECT_TIMEOUT_SECONDS` 30 — **12 per process, 60 of 97 for the fleet**, leaving the one-shot ops container and `psql` room. A typo throws rather than silently degrading, because a bad `KERNEL_DB_POOL_MAX` must never quietly become "unbounded"; only the idle timeout accepts 0, which is postgres.js's old "never close" behaviour kept reachable. (b) **pg-boss is capped, not shared.** It speaks `pg` and the kernel speaks postgres.js; pg-boss 12.25 does expose a `db` adapter escape hatch, but it wants a pg-shaped `IDatabase` (`executeSql` → `pg.QueryResult`, `listen`, `withTransaction`) — a large, easy-to-get-subtly-wrong surface under the whole job system, for slots that `max` returns anyway. Its options object goes straight to `new pg.Pool`, so `max` and `connectionTimeoutMillis` pass through. **`useListenNotify` stays off** (its default): it would hold one DEDICATED session-pinned connection per process ON TOP of `max`, uncapped by any knob here — if anyone enables it for latency, add 1 per process to the arithmetic. (c) **Every pool names itself in `pg_stat_activity`** (`KERNEL_APP_NAME` → `<name>-app` / `-admin` / `-jobs`); before this, every kernel connection said "postgres.js" and every job pool "pgboss", so "which of the five containers is eating the slots?" was unanswerable. (d) **The standalone workers now give their connections back**: `apps/*/scripts/worker.ts` (×5) called `shutdown()` (which stops pg-boss) but never `closeDb()`, leaving both postgres.js pools open so the process could not exit cleanly — and handled only **SIGINT** while `docker stop` sends **SIGTERM**, so in production the graceful path never ran and the container was SIGKILLed ten seconds later with every connection still up. Both fixed, idempotently. Audited clean: the paiduay/demo/clinic/barber/cameo seed, verify, transition, inbox, card-status and card-forget scripts all pair `kernel().shutdown()` with `closeDb()` in a `finally`; `trust-harness/src/run.ts` closes in a `finally` covering every DB read and never starts pg-boss; `card-new.ts`, `shot.ts` and `gen-paiduay-ground-truth.ts` open no pool; `db/migrate.ts` uses `max: 1` deliberately. (e) **Luna and TuaTon share the box but NOT this Postgres** — the cluster has exactly one non-template database (`ai_factory`) and four login roles — so the whole 97 is the factory's. Note for ops: prod Postgres is a HOST cluster, not a container, so `docker ps` will not show it and `sudo -u postgres psql` is the way in. The arithmetic, the numbers read off production, the values for the server `.env` and the watch query live in **`docs/handoff/45-db-connections.md`**. Gate: `@factory/core` typecheck, `vitest run src/db` 19/19 (17 of them the pure budget tests, which fail if a future default breaks the 97-slot budget), `paiduay lib/cards/repo.db.test.ts` 5/5 against real Postgres under RLS. Two findings handed on rather than fixed here: **`.env.example` should gain the five new keys** (it was mid-edit by a concurrent agent, so it was left alone), and **`fixtureSlug()` derives its prefix from the checkout root**, so N agents in ONE checkout share one prefix and delete each other's fixtures mid-run — the DB test only passes reliably with `VITEST_SLUG_PREFIX=<unique>`, which is the per-checkout-prefix gap already on the backlog. Open (James): whether to set the knobs explicitly on the server (the defaults already fit) and whether paiduay gets its own higher `KERNEL_DB_POOL_MAX` via a compose `environment:` override when public traffic arrives.

- **§9.62 — USABLE-MVP Wave 4 (2026-09-17, evening; ten agents + four planners, run through a Mac restart, a context compaction and a session-limit reset; briefs verbatim in `apps/paiduay/docs/MVP-BRIEF-WAVE4.md`).** (a) **Widget on v3** (`a4841ef`): `isV3Path` now returns true for every route, so `data-v3` stays a proxy header rather than a per-page attribute; `/embed/chat` takes `?provider=` (her OKLCH hue via `.v-person`) and `?lang=`; the launcher in `widget-script.ts` carries a *copied* token set because no stylesheet crosses a shadow root, and `manifest.ts` theme equals those values. Contracts unchanged (postMessage, `frame-ancestors *`, noindex, 3-s polling, 403 → stop). The v2 `:root` tokens are **not removable**: `html[data-v3]` overrides 19 of 81, and 48 of the rest are still read by v3 rules — they are a base layer, documented in `globals.css`; 14 are dead and can be retired one by one. Two known limits: the closed launcher cannot see the server clock (only the open panel turns night), and the teaser is the manifest's single Thai greeting even under `data-lang=en`. (b) **Cards on `/browse`** (`edda622`): a section "การ์ด / Cards" below the companion deck (the deck has no empty state — every companion is always listed — so "above when empty" never arises); `app/browse/deck.ts` is pure ordering: published only, `createdAt` desc, active Pro (`isPro` against the real clock, not the simulated hour) lifted into a separate row under one dim label "โปร · ตำแหน่งที่ซื้อ / Pro · paid placement", the free row labelled "ใหม่ล่าสุดก่อน / Newest first" so the paid row reads as the exception; no badge on any tile; `card-tile` is a miniature of the card (4:5 plate, Pridi name, her line, her price, one LINE-green hint, demo line); tap → `/c/<handle>?s=direct`. The Pro page's "featured on browse" line now says it exists. (c) **Video kit v1** (`318df17`): `GET /c/[handle]/kit/video?t=plate|band|story&f=story|square&lang=&night=` → MP4 H.264 yuv420p 24 fps 6.4–7.0 s silent (empty AAC track so composers accept it), 490–915 KB, from the SAME Satori templates via a new additive `hidden: Set<KitPart>` on `renderKit` (hidden parts draw at opacity 0 so layout never shifts) and one ffmpeg run (`xfade` + one eased `zoompan` ≤ 4 %); cache under `<upload dir>/<handle>/video/…` so erasure removes clips; one render at a time, same-key requests share the promise, other keys 503 + `Retry-After: 12`; every clip is Pro, demo cards render all, free → 402; Range/ETag/immutable when `?v=` matches. Gallery row "คลิป / Clips" first (poster = the story PNG, tap warms the render, `<video muted loop playsinline>`, download). Image: Debian `apt-get install --no-install-recommends ffmpeg` in `deploy/Dockerfile` gated on `ARG APP=paiduay` (~250 MB, paiduay image only); `ffmpeg-static` rejected (postinstall download) and `@ffmpeg-installer` rejected (ffmpeg 4.1, no `xfade`). Dev render 2.5–4.9 s per request on an 8-core Mac. (d) **Slip pre-read** (`0dc82e8`): `lib/pro/slip-reader.ts` — `SlipVerifier` with `VisionSlipReader` (one strict-JSON gateway call, metered `pro.slip_read`, six nullable fields; it *transcribes*, every flag is computed in `slipFlags()`) and `EasySlipVerifier` (behind `EASYSLIP_TOKEN`, tried first when set; response field paths marked UNVERIFIED until a token exists); flags `amount_mismatch | too_early | receiver_unknown | duplicate_ref | unreadable`; duplicate `transRef` guard on record data across the tenant's `pro_request`s (the plan's `pd_slip_refs` table deferred, zero DDL); `notBefore` = the card's `createdAt` (Pro has no request moment); the read runs detached after the request commits, a failed read lands as `unreadable`; `/admin/pro` shows the read + flags Thai-first with "การอ่านไม่ใช่การอนุมัติ คุณเป็นคนตัดสิน" and a re-read action — approval stays a human act. Measured cost ≈ 2,578 in / 90 out tokens per read (≈ ฿0.50), ~16 s. (e) **Notifications** (`0609143`): `companion.unanswered` is enqueued at create for +`PAIDUAY_UNANSWERED_HOURS` (default 4) and re-reads state instead of being dequeued (an amended request is still unanswered and keeps the original clock); dedupe key `request.unanswered:<id>`. `meetup.reminder` is driven by a new bus event `booking.reminder.sent` published by scheduled-interactions' reminder job after its thread post — one timer owns both surfaces so the thread and the phone cannot disagree and a cancelled meetup silences both; audience provider only. The reminder's thread line moved to manifest labels `booking.reminder.message` + `booking.reminder.timezone` (defaults preserve behaviour); **`{when}` was the server's clock — on the Germany box a 10:00 Bangkok meetup would have been reminded as 03:00**; fixed via `reminderWhen(ctx, iso)`. Found, not fixed: 21 `booking.reminder` jobs failed since 2026-09-10 with `NotFoundError` because the reminder job does not catch a deleted record. (f) **Card plate performance** (`5437eff`): `<picture>` with WebP + JPEG `srcset` from `photo.variants`, an explicit `<link rel="preload" as="image" fetchpriority="high">` with `imagesrcset` (React's automatic JPEG preload replaced; `ReactDOM.preload()` lands after the head flush under Next 16), `width`/`height` + `picture { display: contents }` → CLS 0; WebP twins committed for the two demo cards (mint 219 KB → 99 KB). Measured over CDP at 390/DPR 3, Slow-4G, CPU 4×, median of 3: dev LCP 7,864 → 4,964 ms; prod before 4,404 ms, prod after pending this deploy; Lighthouse unavailable locally. Left alone with reasons: fonts (`swap` + unicode-range already), `/go` hop (server 302, no client JS). **Biggest remaining cost: Clerk is 371 KB of the 908 KB public card page (41 %) on a page with no session** — keeping `<ClerkProvider>` off the public journey is the next perf item. (g) **Clerk webhook** (`dfb14b8`): `POST /api/clerk/webhook` verifies the Svix signature (401 on failure, 503 while `CLERK_WEBHOOK_SIGNING_SECRET` is unset — unsigned events are never processed), `user.deleted` → `forgetCard` for every card the user owns (tombstones per §9.60) + `owner.deleted` outbox event with a count only; idempotent on replay; setup for James appended to `docs/CLERK-SETUP.md`. (h) **Uploads backup** (`39bd993`): the nightly `pg_dump` verified by a real restore into a throwaway DB (plain-SQL dump → `psql`, not `pg_restore`; it creates neither the database nor the roles — documented); `deploy/backup-uploads.sh` tars the `ai-factory_paiduay-uploads` volume nightly at 03:40 (cron line added on the server, prior crontab saved), 30-day retention (shorter than the 90-day photo policy), `.part` + verify before rename; restore rehearsed; runbook in `deploy/README.md`. Nothing is off-box; MinIO is a second single machine, not durable storage; recommended R2 via rclone. (i) **Connection budget** — §9.61 above (A20); the six env values are also now explicit in the server `.env`; Postgres listens on 0.0.0.0 but ufw admits 5432 only from the Docker subnet and the tailnet (checked from outside: closed). (j) **Plans written tonight**, all in `docs/paiduay/plans/`: `next-sprint.md` (the pick-one menu; top three: LINE Login via Clerk, analytics N5 residual + digest, operator hide → photos purged now), `matchmaking.md` refreshed (a board not a desk: `moment`/`answer` records, zero DDL, ≈ 20 h est.), `card-as-module.md` refreshed (four PRs; record/lifecycle/erasure/photo transform move behind a `PhotoStore` seam, everything visual stays), `module-schedules-seam.md` (`ModuleDef.schedules` registered in `startWorker` via pg-boss `schedule()` with `key = job`, zero new pools, ~7 h), and `apps/paiduay/docs/CLERK-PRODUCTION-RUNBOOK.md` (no code change needed for the key switch; Clerk dev instances cap at 100 users; Cloudflare proxying breaks Clerk's DNS validation). (k) **Orchestration notes:** the Mac restart and the session limit each cut agents mid-flight; work on disk survived both and was resumed by re-dispatching the same brief with a "your files are on disk" line — five of the ten needed it. The bridge commit `2d44821` unintentionally swept A16's and A20's *staged* stash restores into history; harmless, both built on it. One agent (A21) survived the limit and finished 76 minutes later while its resume twin was running — the twin was stopped before it committed; the lesson is to check `git log` for a late commit before re-dispatching a "failed" agent. James's model rule for the night: Fable where the task benefits, Opus where the gain is small (five and five here).

- **§9.63 — Sales wave (2026-09-17, 23:13 → ~00:10; James: "burn the remaining Fable quota on what I can make a sale with").** Thirteen agents, all landed. (a) **`/sell`** (`da799b3`, critic-fixed `d3530f7`): the seller front door James tweets — one line of what a card is with the labelled demo plate, three steps in her words, free vs Pro in one honest pair, the safety paragraph, one button → `/c/new?s=sell`; the promise line "ฟรี ทำเองบนมือถือในสามนาที แก้เองได้ ลบเองได้ ไม่หักอะไรจากนัด" sits under the poster because the posts send her with exactly that; screen Gil (pending). (b) **`/pro`** (`0a5f921`, `d3530f7`): the public one-pager — the browse row drawn only as its two labels (never a fabricated mini-directory), four real demo renders (band free; plate/split/night Pro) + one clip poster, the PromptPay steps with the honest "ยังไม่เปิดรับชำระ" box directly under the doors while `PAIDUAY_PROMPTPAY_ID` is unset, what Pro does NOT do (no boost, no badge, not "verified"); the operator's "คุณเป็นคนตัดสิน" was removed from the public page; screen Moe (pending). (c) **Home doors** (`51c49cd`): the foot gains "ทุกคน · การ์ด" → `/browse#cards` (the home had no `/browse` link at all) and the seller line → `/sell?s=home`; `id="cards"` on the browse section. (d) **Clerk off the public journey** (inside `82a6fe2` by a commit race): `<ClerkProvider>` moved from the root layout into six nested owner layouts (`/me`, `/c/new`, `/c/[handle]/edit`, `/c/[handle]/pro`, `/sign-in`, `/sign-up`) sharing `components/owner/clerk-shell.tsx`; public pages ship zero Clerk bytes; `/c/mint` on dev 1,524 → 992 KB, LCP −808 ms, the localization pack no longer serialised into every public page. (e) **Share previews** (`a82212d`): OG images were already right (1200×630, tone marks OK); the head was a coin flip — Next streams metadata to unrecognised crawlers, measured `LINE-SPOT/1.0` got the tags 4/8 times on prod; fixed with `UNFURLER_UA_RE` in `lib/seo.ts` passed as `htmlLimitedBots` (replaces Next's default, so the default is reproduced verbatim + LINE, Bytespider, Telegram, Viber, Pinterest); `shareMetadata()` now returns alternates + openGraph + twitter as one object; a page declaring `openGraph` drops the ancestors' file-convention image (caught on `/browse`, `/policy`); `og:site_name` everywhere. (f) **Share images for `/sell` and `/pro`** (`82a4d44`): file-convention `opengraph-image.tsx` on both segments via a shared `lib/og-door.tsx`; a segment's own image survives its `generateMetadata`. (g) **Customer funnel critic** (`4e08d36`): the trust line "ไม่มีมัดจำ จ่ายหลังจบนัด · กลางวัน ที่สาธารณะ" now sits under the LINE id at the money moment (it was only in the 13-px footer); tile word-breaks fixed with `text-wrap: balance`; **left for James: the cards section is the last thing on `/browse` (~4,600 px down) — moving it above the deck reverses §9.62 (b) and is his call**; the receptionist on `/p/ploy` accepts on her behalf ("พลอยรับได้ค่ะ") — a companion-prompt issue, reported. (h) **Seller funnel critic** (`5224474`): a free seller's kit at night was a wall of padlocks (night = Pro, clips first) → free cards open the kit in day and the locked clips row moves under the stills; the 3-card cap counted operator-hidden cards and `/me` offered a 404 "view" on them → cap counts `status !== "hidden"`, hidden rows show one honest line; the first publish tap failed once during an unrelated HMR error — not reproduced, verify on prod. (i) **Sales collateral, docs only:** `docs/paiduay/launch/posts-2026-09.md` (`fd6536c`: 12 posts TH/EN with `?s=` tags, 8 reply scripts, 7-day calendar, never-say list), `docs/paiduay/launch/one-pager.html` + story/A5 PNGs (`e7cb8fd`; QR to `/sell` verified by decoding; screen Lisa placeholder), `docs/paiduay/launch/seller-messages.md` (`ac1cc95`: what James sends by LINE at publish/day 3/day 7/nudge/slip/pause, the five answers, the 10-minute operator loop; note: `pro.approved` buzzes James, not her — the hand-typed message is the replacement), and **`factory.html` at the repo root** (`cd72ad6`): the factory's sales page, served by the hub at `https://6326638.xyz/factory.html` after `git pull` on the server (the hub is Caddy `file_server` over the checkout); `mailto:`/LINE placeholders for James; screen Herb. (j) **Open before the first post goes out:** the posts and the owner Pro page promise "เปลี่ยนชื่อลิงก์ได้หนึ่งครั้ง" but there is no owner UI for a handle change — build it or pull the line; the sign-up form still shows a username field (Clerk dashboard, James); the number domain is what LINE prints under every unfurl (a domain purchase is the cheapest improvement to the ad); the `/join` and `/sell` doors both sit in the home foot — fold once `/sell` is the door James tweets.

- **§9.64 — Paiduay: the one link change (2026-09-18, agent S14, ~35 min).** The Pro page and the P1 launch post promise "เปลี่ยนชื่อลิงก์ได้หนึ่งครั้ง" (`content/copy-pro.ts` `unlockHandle`, listed under "Pro unlocks" → Pro-only, once); S12 found no owner UI. Built exactly that. (a) **Repository** (`lib/cards/repo.ts`): `renameHandle(handle, newHandle, by)` — owner needs active Pro (`needs_pro`) and an unset `data.handleChangedAt` (second attempt → `handle_already_changed`); creation's validation (`validateRename`, pure); the new handle must be free of cards **and of live forwards**; the old handle is kept on `data.previousHandle` + `handleChangedAt`; the photo directory under the upload dir moves with the handle inside the transaction (its URLs carry the handle — `rehomePhoto` rewrites `photo.src`/`variants`; a failed write moves it back). An owner can no longer change `handle` through `updateCard` at all (it was Pro-gated and unlimited, with no forward); the operator still can (plain rotation, what `--release-handle` relies on) and may `renameHandle` freely. `previousHandle`/`handleChangedAt` join `OWNER_LOCKED_KEYS`. (b) **The forward — decision:** `/c/<old>` answers a permanent redirect to `/c/<new>` for `HANDLE_FORWARD_DAYS = 90`, query preserved (308 from the pages via Next's `permanentRedirect`, 301 from the route handlers `go`, `kit/image`, `kit/video`, `opengraph-image`; `kit`, `edit`, `pro` pages forward too); `isHandleFree` (creator's check, `createCard`, operator `updateCard`) says **taken** for a handle held by a live forward, so nobody inherits the old link's audience while it still follows it (forget.ts's impersonation argument); a card may take its own old name back. After 90 days the old link 404s and the name is free — her audience has had a quarter of permanent redirects to move; the window equals the photo-retention window. `/c/<old>/photo/*` is deliberately NOT forwarded: that route does no lookup by design (hot path), and the files moved with the directory. (c) **UI** (`components/editor/handle-ask.tsx`, in the edit menu of Martin): row "ชื่อลิงก์ · /c/<handle>"; the panel has three honest states — not Pro (one line + "ดูโปร"), Pro and unused (the creator's own availability check via `checkRenameHandle`, the 90-day line, a confirm line, one button), used ("ใช้สิทธิ์แล้ว" + from/when/forwards-until). Success navigates to the new edit URL (the editor is keyed by handle). Copy appended to `content/copy-editor.ts`. (d) **Proof:** pure tests (validation, `forwardLive` 90-day edge, `rehomePhoto`), DB tests (old link forwards, second rename refused, old handle not free for create/rename, free again once the forward ages out, operator re-rename refreshes the forward, owner B refused on A's card); live on dev signed in as the Clerk test owner over CDP: `s14-a` → `s14-b` through the panel; `/c/s14-a` 308 → `/c/s14-b` (200), `go/line` 301 → the new hop → 302 to LINE, kit/image/OG 301, edit 307 to sign-in when signed out (the door comes first); the card is hidden again. Screenshots `apps/paiduay/docs/screenshots/mvp/s14-0[1-4]-*-390-{th,en}.png`. Not done: the `/c/<old>/photo/*` forward (above), the P1 post wording is untouched (it is now true).

- **§9.65 — The receptionist never accepts on her behalf (2026-09-18, S16, from the customer-funnel critic's finding).** Root cause: the violation happened before the tool call — while collecting details the model read "her listed hours contain your time" as "she accepts" ("พลอยรับได้ค่ะ"); the BOOKING paragraph never forbade speaking for her and the tool result was English-only. Fix: the BOOKING paragraph in `content/ai.ts` forbids accepting/confirming/promising for her and gives the allowed sentence shape; `createMeetupRequest`'s tool result (`requestRecordedMessage()`) is Thai-first and says "ส่งคำขอให้<ชื่อ>แล้วนะคะ เดี๋ยวเธอตอบเองค่ะ — nothing accepted, nothing booked". Harness: checks `no-acceptance-on-behalf` (SEV-1) and `request-framed` (SEV-2), battery `fixtures/paiduay/request.json` (7 fixtures, 9 utterances incl. the critic's verbatim message); unit 127/127; live vs the new prompt 26/26 claims held on 4 fixtures — **the last 3 fixtures are UNTESTED because the Anthropic account ran out of credits mid-run**; re-run `pnpm trust-eval --app paiduay --battery request` after the top-up. The prompt is read at kernel boot → deployed with this integration.

- **§9.66 — Frenday: the name, the host, the model (2026-09-18).** (a) **Name.** Two research agents (Thai-market census of 76 brand→domain pairs, technical/abuse data, Thai police and consumer-media sources) found `paiduay.com` is a live Thai travel platform, `@paiduayy` (IG 102K, also TikTok) runs the same product in Bangkok, PaiduayTech ships the Daywork app, and ไปด้วยกัน owns the search term; `.xyz` is named by Thai cyber police and MoneyHub/DroidSans as the fake-site suffix, `.app` is clean but 1 of 76 Thai brands use it; `.in.th/.co.th/.th` are closed without a work permit, trademark or Thai company. Two naming agents (Thai words; coined brandables) shortlisted ไปกัน/paigan, มาด้วย/maduay, คู่เดิน/kudern, เคียงกัน/kiangkan, Frenday, Bainat, Baiday. **James chose Frenday, Thai เฟรนเดย์ (approved), and said he may change his mind.** `frenday.com` is a squatter's parking page expiring 2026-11-23 (backorder); `@frenday` is taken on TikTok/IG by dormant accounts (use `@frenday.th`); X handle and trademark unverified. (b) **Host.** `frenday.xyz` is the launch host as a *temporary* measure (James's words: small chance it stays, large chance `.co` or `.app` in the coming weeks; research ranks `.co ≥ .app > .xyz`). Cloudflare zone `ce4104774801fc2537109f9a8db95123`, A → 92.118.206.89 DNS-only, `www` CNAME; Caddy block `frenday.xyz, www.frenday.xyz` with the same TLS (DNS-01 via the Cloudflare token) and secure headers; **`paiduay.6326638.xyz` 301s to the new host** so every link, QR and post already in the wild keeps working. Per-service `NEXT_PUBLIC_SITE_URL` / `PUBLIC_ORIGIN_SUFFIX` in `deploy/compose.yaml` (the `*.6326638.xyz` demos keep the shared values) — but Turbopack bakes `NEXT_PUBLIC_SITE_URL` into the server chunks at build time from the env file the Docker build reads, so the shared server `.env` had to carry the new host too and the image was rebuilt; the compose override alone left `og:url` on the old host. Lesson: a host change is a rebuild, not a restart. (c) **Rebrand in code** (`9b46ea3` copy, `35693ad` visuals, `98e292e` collateral, R4 sweep): brand uses only — **ไปด้วย stays wherever it is the verb** ("ไปด้วยได้", "หาเพื่อนไปด้วย", the accept button, the tagline "ไปด้วยกันนะ" which now no longer puns — James may want a new tagline); the receptionist prompt changed only the name token, doctrine paragraphs byte-identical; notification titles "เฟรนเดย์ · …"; the wordmark is a re-extracted Pridi SemiBold path (`WORDMARK_PATH`, ratio 3420/850, Pridi has no GPOS mark feature so ์ over ย sits at the pen position) — **the logomark is unchanged, James's**; OG images, kit, poster print the host from `siteOrigin()`; one-pager PNGs regenerated with a QR to `frenday.xyz/sell` (decoded back from all four rasters); Clerk docs note the dev instance must allow the new origin. Internal identifiers do not change: `apps/paiduay`, tenant slug `paiduay`, `PAIDUAY_*` env, cookie `pd_chat`, `x-paiduay-*` headers, screen names, spec text, historical QA/proof docs. (d) **Model.** James ordered Haiku 4.5 everywhere (`86b392e`): one env default, `KERNEL_MODEL_DEFAULT=claude-haiku-4-5` (the id is complete without a date suffix). Caught before deploy: the Anthropic adapter hardcoded `thinking: { type: "adaptive" }`, which Haiku 4.5 rejects → `thinkingFor(model)` sends no thinking on Haiku 4.5 / older, adaptive on 4.6+. (e) **Gemini** (`0dba056`): `GeminiAdapter` on the official `@google/genai` (function-calling loop, `responseSchema` JSON, `gemini-embedding-001`, usage from `usageMetadata`), `KERNEL_PROVIDER=anthropic|gemini` with `createProviderAdapter`, `OPENAI_API`/`OPENROUTER_API` parsed and reserved (no adapter). Verified `gemini-3.5-flash-lite` (1M in / 64k out, thinking). Live: receptionist 12.7k in / 95 out, strict JSON, and the vision slip read all correct — but the prompt is Claude-tuned: flash-lite restated the profile window instead of answering "is Sunday 15:00 free", asked for IG though LINE was given, and never fired `request_meetup` (gathered instead of acting); the §9.65 doctrine line survived. `maxOutputTokens` includes thinking tokens on Gemini — a low per-call cap returns an empty candidate. **The Gemini key is IP-allowlisted:** VPS IPv4 yes; VPS IPv6 egress `2a0e:97c0:3ea:90a::1` and James's Mac (`61.90.27.73`, its IPv6) no — dev needs the Mac IP added (or the CONNECT tunnel G1 left running), prod on Gemini needs `NODE_OPTIONS=--dns-result-order=ipv4first` or the IPv6 added. Dev runs Gemini now; **prod stays on Haiku until James decides** (the switch is `KERNEL_PROVIDER` + `KERNEL_MODEL_DEFAULT` + restart). James acknowledged the prompt/harness/grounding were tuned on Opus 4.8 and ordered proper per-model tuning later — WO-7 in `docs/foundation/work-orders/ai-gateway-cost-and-resilience.md` (approved, deferred, with WO-1 caching, WO-2 grounding trim, WO-3 routing, WO-4 error alerts, WO-5 cost view, WO-6 harness spend guard; a full harness pass is ~94 turns ≈ 2M input tokens — never run without his OK). (f) **Open on James:** PromptPay id; Clerk production instance (needs a domain he keeps); `CLERK_WEBHOOK_SIGNING_SECRET`; Anthropic credit (exhausted twice by harness runs); Gemini allowlist additions; Haiku vs flash-lite; the `.co`/`.app` decision; a new tagline.

- **§9.67 — The eight demo companions get portraits, and the portrait says it is AI (2026-09-18, agent D1).** `/browse` and every `/p/<slug>` drew a monogram plate and "ยังไม่มีรูป ตอนนี้{nickname}คือสีนี้" — a directory of eight blanks on the surface a cold TikTok link lands on. **(a) The pictures.** One portrait per companion, generated with Higgsfield → `gpt_image_2_5` (4:5, quality high, 2k → 1792×2240, 3 credits each), one shared style block (candid documentary, natural daylight, 50 mm at f/2.8, medium shot with headroom, modest everyday clothes, no glamour, no legible text, no second face in focus) and a person-half drawn from her own record: **her stated age and sex** (the brief assumed "Thai woman, 20s" — the records are 22 to 38, five women and three men: Tae 27, Beam 22 and Gun 33 write ครับ/ผม), her plate's hue carried in the scene (Ploy's pink tote against a pink café wall; Beam's violet t-shirt), and a real Bangkok place from her `areas`/`activities`. Every image was read before it was kept; seven passed first time, **Nok needed one retry** — "aged 38" plus "a few greys" returned a woman reading mid-forties, fixed by stating the number twice and naming the direction ("and looks 38, not older"). Busy corners (a Yaowarat shopfront, Beam's shelves) were re-read at native resolution, because a 900 px preview hides lettering. **(b) Files.** `public/people/<slug>.jpg` 1280×1600 q82 + `<slug>.webp` + `<slug>-640.webp`, sharp, no `.withMetadata()` so no EXIF/GPS survives — the `public/cards/` demo-twin pattern one size up. `plateSources` gained a `/people/` branch (+1 test); the v3 plate now renders `<picture>` (WebP → JPEG, `sizes="(min-width: 720px) 44vw, 100vw"`, `width`/`height`, `picture { display: contents }`, `.v-photo-src + .v-mono` so the monogram still hides) and a deck plate loads lazily — eight photos on one page. **(c) The label — the decision.** A generated face is exactly what the doctrine forbids for a real seller, so the permission is carried by the record, not by the page: `CardPhoto.generated?: true`, set only on these eight, and the plate prints **"ตัวละครสมมติ · ภาพสร้างด้วย AI" / "Fictional character · AI-made picture"** (`v3.photoAi`) whenever it is set. A real seller's photo therefore cannot print it, and a generated picture cannot be shown without it. The line sits **on** the picture, which the plate's own docstring allows only behind an AA scrim band: a full-bleed gradient holding 66 % black over the glyph height, measured off the shipped 390 shots at **6.2:1** worst case (white on the band). One language per render, as everywhere. **(d) Honesty repair.** `lib/poster.tsx` printed "ยังไม่มีรูป" on the OG/share board's monogram plate; that is now false, so the caption is dropped when she has a photo (the board's foot already carries the demo line). **Drawing the photo into the board is the follow-up** — `loadCardPhoto` + `coverCrop` in `lib/kit` already do the work; it was left alone because the OG routes were being edited by the concurrent rebrand session. **(e) Regeneration.** `docs/paiduay/content/demo-photo-prompts.md` keeps the exact prompt per companion, the style block, the reject rules and the file spec, so any model can remake any one of the eight — one prompt, one image. **(f) Deploy:** nothing to re-seed. The photo lives in `content/providers.ts` and the files in `public/`; the seed writes only headline/bio/category/price/active. `db:seed` on prod is unnecessary; a deploy of this commit is sufficient. Gate: `pnpm --filter paiduay typecheck` clean, 421 paiduay tests green, six 390 shots read (`apps/paiduay/docs/screenshots/rebrand/d1-*.png`).

- **§9.68 — `/story` and `/our-story`: the third door (2026-09-19).** Frenday had two doors — `/sell` (the seller funnel) and `/browse` (the customer directory) — and nothing to send a mentor, a partner or a journalist who asks "what is this?". This is that page: eight full-height screens, one idea each, no header and no footer, readable by thumb in 20–30 seconds with no interaction beyond scrolling. Copy lives in `apps/paiduay/content/story.ts` (written by a second agent against a typed contract in the same file's header), treatment in `apps/paiduay/components/story/`. `screen.visual` is the whole contract between them: `wordmark` · `frames` · `demo-card` · `none`. **(a) English is primary — on these two routes only.** James's order: every stakeholder he sends the link to reads English, and his mentor reads no Thai. The precedence is a pure, unit-tested function (`lib/story-lang.ts`, 18 tests): `?lang=` → the `lang` cookie → `Accept-Language` (first *preferred* tag, `q=` respected; `th`/`th-*` → Thai) → English. The cookie is honoured **because on this site a present `lang` cookie is always an explicit act**: it is written in exactly two places, the `setLang` server action behind the toggle and `GET /api/lang`, and `proxy.ts` carries `?lang=` as a request *header*, never as a cookie — so a visitor who arrived on a shared `?lang=` link never acquires one by accident, and honouring it can never override a person's own choice with a browser setting. Everywhere else on the site Thai stays the default (`lib/i18n.ts`), unchanged. **(b) `<html lang>` without forking the layout.** Only the ROOT layout may set `<html lang>`, and Thai line-breaking plus the per-language line-height tokens depend on it. `proxy.ts` therefore runs the *same* pure function for these two paths and hands the answer to the layout through the existing `x-paiduay-lang` header — the mechanism `?lang=` already used. No route-level `<html>`, no second cookie, no other route's resolution changed. (A `Vary: Accept-Language` appended in the proxy was tried and removed: Next writes its own `Vary` for RSC responses and overwrites it. The pages are `force-dynamic`, so nothing of ours caches them; the day something in front does, the header belongs in `next.config`'s `headers()`.) **(c) The placeholder mechanism.** `/our-story` appends a ninth screen about the founder, written about Homer Simpson on purpose so James replaces exactly those facts by hand. While `FOUNDER_SCREEN.placeholder` is `true` **four things read that one flag**: `robots: noindex, nofollow` in `generateMetadata`, absence from `app/sitemap.ts`, `Disallow: /our-story` in `app/robots.ts`, and a visible "placeholder / ข้อความตัวอย่าง" chip plus `data-placeholder="true"` on the screen. Flipping it to `false` is the entire publish (the sitemap and robots lines are one edit each, both commented to say so). **(d) Cast.** The Groening cast (`lib/screens.ts`) is extended to **The West Wing** for this page family, pre-approved by James: `story: "Toby"` (Ziegler) and `ourStory: "Sam"` (Seaborn) — the two speech writers, because these screens are nothing but prose. Two screens that look different get different names, so the same scroll with one extra screen and a different index policy gets its own name. Public surfaces keep the name in markup (`data-screen`), never on screen; each section carries `data-screen-id`. **(e) Motion and snap.** `scroll-snap-type: y proximity` on the DOCUMENT scroller (via `html:has(.sy)`, so it is confined to these routes) — **proximity, never mandatory**: a reader who flicks past three screens must never feel held, and a nested `100dvh` scroller would have cost the phone's collapsing address bar and pull-to-refresh. Screens fade up once via one `IntersectionObserver`; the pre-state is added *by JS* (`.sy[data-motion="on"]`), so no-JS and `prefers-reduced-motion` readers get the page fully visible with nothing waiting on a script. The only chrome is a 2px progress line driven by the same observer (no scroll listener) and the two flags. **(f) Deviations from the brief, and why.** (i) The brief asked for ~18px body text *and* ≤34 Thai characters a line; measured on a 390px viewport, 18px Anuphan puts ~46 Thai characters across, so Thai body type is 21px with a 15.5em measure (~34 characters) and English stays 18px — Thai wanted the extra height for its marks anyway. (ii) The "demo-card" screen renders the real `CardTile` for handle `mint` rather than a story-only mock, so the card on this page can never drift from the card the reader is about to open; the AI-portrait label `v3.photoAi` ("ตัวละครสมมติ · ภาพสร้างด้วย AI") is printed beneath it by this page because `mint`'s record carries `demo: true` but not `photo.generated`, and a fictional person's picture must say so either way. (iii) `justify-content: safe center` on every screen: the demo-card screen overflows a short phone, and an overflowing screen must lose its bottom, never its eyebrow. (iv) `HOW_BUILT` is a closed `<details>` on the last *story* screen (`reach`) on both routes, not on the founder placeholder. **(g) Share.** `generateMetadata` from `STORY_META`; `opengraph-image.tsx` per route through a new `components/story/og-story.tsx` — the `OgDoor` grammar flipped, English line big and Thai at the foot, because these links are shared in English. `alternates` are inverted for these two paths only (`storyAlternates`: the bare URL is the English page and `x-default`, Thai carries `?lang=th`), and `app/sitemap.ts` lists `/story` through `enFirstEntry` for the same reason. Verified live on dev at 390×844 and 1280×900, both languages, all four precedence cases, `/robots.txt`, `/sitemap.xml` and both OG images.
  - **§9.68 review round 1 (2026-09-19, James on a Pixel 7 Pro).** Copy AND treatment reworked by one agent; the `StoryScreen` contract kept its exported names and grew (`can`/`cannot`, `callouts`, `blocks`, `signin`; visuals `can-cannot` · `card-anatomy` · `blocks` · `today`; `demo-card` and the eyebrow field are gone). What changed and why: **(2) Scroll — no snap at all.** `scroll-snap-type: y proximity` was "not natural, snap barely there" on Android Chrome; the choice was (a) no snap with `min-height: 100dvh` screens and a strong vertical rhythm, or (b) `mandatory` snap with every screen guaranteed to fit — impossible with Thai body text plus the card. (a) shipped: the document scrolls like a document, nothing intercepts a flick, the address bar collapses, pull-to-refresh works (`overscroll-behavior` untouched, verified `auto`). The per-screen fade-up is gone too (a generic tell, and one more thing between the thumb and the page). What remains is one `IntersectionObserver` driving the 2px progress line — no scroll listener, one array lookup per callback. **(3) Eyebrows removed entirely** — "01 · The name" read as generated; nothing replaces them, the progress line already says where you are. **(4)** "public places"/"สาธารณะ" → "open, everyday places"/"ที่ทั่วไปที่มีคน" everywhere (James: "public" reads outdoor-only). **(5)** Screen 2's examples are now social posts, comments and DMs. **(6) The agent is Kathy / แคธี**, one exported constant `KATHY` interpolated as `{kathy}` through `storyPick()`; introduced once on screen 3, by name after; "receptionist"/"ผู้ช่วย" gone (the Thai "ผู้ช่วยไม่มีสิทธิ์ตอบว่าได้" was not native — now "แคธีตอบได้ แต่รับนัดแทนไม่ได้"). **(7–8)** The two sides are named on every screen — *a companion* (เพื่อนเที่ยว) and *a customer* (ลูกค้า) — no bare "she"/"เขา" (test-enforced); screen 3 is "One card. Kathy. One request." (two lines at 412px) and screen 4 "Kathy can read. Kathy cannot say yes.", the four platform rules folded into its one body sentence instead of standing bare. **(9) Non-text visuals**: screen 4 draws Kathy's limits as one object — a tone block of what she can do beside an INK block of what she cannot, the one bold moment on the scroll; screen 3's three objects lost their numbers and borders. **(10) No horizontal lines anywhere** on these routes (list hairlines, frame borders, the fold's rules, the founder screen, the OG image's rule): grouping is space, type scale and the paper's second tone; a Playwright check asserts zero `border-top/bottom` and zero `<hr>` inside `.sy`. **(11–12) Screen 5 is about the card as a product**, not about fiction: "One page. Everything a stranger needs." over a `card-anatomy` visual — the REAL `mint` record taken apart row by row (photo, name + line, what she does, rate, areas, the one button, the other channels) through the same helpers the public card uses, each part level with its caption; no leader lines. The fictional/AI-made fact is said exactly once, by the small `v3.photoAi` label under it. **(13) Money is scannable**: headline, one sentence (paid in person, Frenday takes nothing), then four tone blocks — Free · Pro ฿499 once for twelve months (every kit template incl. night, one handle change, a labelled paid row) · How Pro is paid (PromptPay slip, a person approves within one working day, no auto-renew) · Never (no boost, no badge, we verify no one and never say we did). **(14) Today** = "Launched September 2026, Bangkok." + two blocks (a customer can: open any card, ask Kathy, send a request; a companion can: publish a card in minutes, go Pro — no payment method named on this screen, James: naming one reads as "only one") + the sign-in row as a VISUAL: five drawn monochrome glyphs, email in ink (offered), LINE in LINE green (next), Instagram/Google/Facebook dimmed (not offered), legend "Sign in with email today. LINE next." — the state is hard-coded to CLERK-PRODUCTION-RUNBOOK §2.9 and each glyph's accessible name says its state. "First sellers onboarding", "no numbers", "built by one person" and the taps/90-days line are gone. **(15) Reach** rewritten as one call — "Enough reading. Open the real thing." — the old defensive line is gone, the single `/browse` link is now a filled button. **`HOW_BUILT`** is cut from `/story` and kept on `/our-story` only, folded under the founder screen, rewritten in three plain sentences without kernel/vertical/adapter/tenancy (test-enforced). **Flags** are inline SVG (no emoji anywhere on these routes). **Thai line breaks**: the browser split "เฟ|รนเดย์" at 412px; `noBreak()` in `story-page.tsx` inserts U+2060 word joiners into the product and rail names at render time, so the copy file stays plain text. Verified on dev at 412×915 (Pixel 7 Pro CSS viewport) both languages, every screen, plus 1280×900: `scroll-snap-type: none`, no borders, no `<hr>`, no emoji, no horizontal overflow; 461 app tests and root typecheck green. Item 1 of the review (measuring `?s=mentor` on `/story`) was dropped by James mid-round; nothing in `lib/hits` changed.
  - **§9.68 review round 2 (2026-09-19, James on a Pixel 7 Pro again).** Three complaints, one root cause each. **(A) “Snapping” — solved by removing the screen.** Round 1 took the snap out but left `min-height: 100dvh` on every section, so a short one (today, reach) left ~300px of dead paper before the next headline and a tall one (the anatomy, 1050px at 412×915) lost its bottom below the fold — which reads exactly like a broken snap. James: “couldn't we just do things normally as many other websites do?” So: **no element on these routes has a viewport height any more.** A section is its content plus one shared rhythm (`padding-block: clamp(56px, 12vh, 96px)`, the first section taller so the lockup has sky, the last taller so the document has a foot); `justify-content: safe center` is gone with it. At 412×915 the sections now measure 428–1225px and the page is 5.7k (EN) / 5.8k (TH) instead of 8×915 — no clipping, and the only gap between two ideas is the 192px rhythm. The 2px line stays but now reads what it looks like: reading progress through the document (one passive scroll listener coalesced into a rAF, one division — the old IntersectionObserver asked “which section fills the viewport”, a question that no longer has one answer). **(B) The card screen was a spreadsheet, and it dissected the wrong artefact.** The audit's finding: it took apart the self-serve card `/c/mint`, whose one button is “Add on LINE to book” and which has no Kathy, while screens 3, 4 and 7 promise asking Kathy and sending a request — both of which exist only on a companion's page `/p/<slug>`. It now draws **Ploy's companion page** (`content/providers.ts`, her AI-made portrait labelled once by `v3.photoAi`) through the helpers her own page uses (`plateSources`, `formatBaht` + `v3.perHour`, `ACTIVITIES_BY_ID`/`AREAS_BY_ID`, `t("v3.ask")` for the button's real label, her `availability` sentence verbatim), so the mock cannot drift from the product. The treatment is an **exploded stack**, not a part-beside-caption grid: five tiles of different shapes on HER paper (`.v-person` tokens, so night comes free), each carrying its caption INSIDE it — who she is · what she does and where · the rate beside the hours · where a customer asks Kathy (her chat chip + composer) · the one button. `visual` renamed `card-anatomy` → `page-anatomy`, `callouts` 7 → 6, the body's “It is free” cut (free is the *card*'s fact, said on the money screen), `StoryPageProps.card` → `companion`. **(C) Money and today were four and two identical boxes.** Money is now ONE composition: a light `Free` tile beside an ink `Pro ฿499` tile (`StoryBlock.note` carries “once, for twelve months” under the number), and everything after the two prices — how Pro is paid, what ฿499 does not buy — is quiet text with no box; `today`'s two blocks and the founder's three points get the same treatment (the founder's keep the sun-disc marker the can-list already uses). The sign-in glyph row and its legend are untouched. Two ink blocks now exist on the scroll (Kathy-cannot and Pro) — a deliberate deviation from round 1's “one bold moment”, because James asked for the price contrast. Thai got its own leadings where a display size would clip the tone marks. **Verified** on dev at 412×915 both languages, every section, plus 1280×900 and a simulated 22:00 (night): `scroll-snap-type: none`, no `100dvh`/`100vh` in the sheet, `overscroll-behavior: auto`, zero `<hr>`, zero `border-top/bottom` inside `.sy`, `scrollWidth === innerWidth === 412`. 461 app tests and root typecheck green; shots replaced in `apps/paiduay/docs/screenshots/story/`. **Left for James (not fixed here, both site-wide decisions):** the product calls Kathy “ผู้ช่วย / receptionist” on `/p/<slug>` while the story calls her Kathy (so the drawn ask box is her real composer without the title bar), and the story says “card” on screens 3/6/7 while the artefact it draws is a companion page — the two products have not converged.
  - **§9.68 — the card screen's chat is LIVE (2026-09-19, James's order item 4).** The ask tile on the `page-anatomy` screen was a drawing of a composer: a chip and a dead field, so the one question the page invites ("what would I actually ask?") could only be answered by leaving it. It now mounts the REAL chat — the same `ChatClient` her own page mounts, on the same `POST /api/chat`, with the branding built by the same function. **Reuse, not a copy:** the label/suggestion block that lived inline in `app/p/[slug]/page.tsx` moved to `lib/companion-chat.ts` (`companionChat(provider, lang)`) and both surfaces now call it, so a marketing page cannot promise something the product does not say. **The thread** is minted in the browser as `p_<slug>_<uuid>` (`lib/conversation.ts`) and kept in **`sessionStorage`** (`components/story/story-chat.tsx`): one conversation per tab, surviving a reload — a server-minted id would open a new conversation on every render of a `force-dynamic` page, and `localStorage` is deliberately left to `lib/provider-thread.ts` so a reader trying the shop window does not silently resume (or take over) their real thread with that companion. Everything else follows from the id and the route and is therefore identical to `/p/<slug>`: the companion tools bound by closure, O23 possession (the first poll 403s until the first POST — same on her page), both rate limits, the tenant's daily token budget, the safety rules. **No new limiter was added** (James: the spend edge is already guarded). **Layout:** `.sy-an-chat` is the one bounded height on these routes — `clamp(320px, 58dvh, 520px)` with the thread scrolling inside it and `overscroll-behavior` left to chain, so at the end of the thread the flick carries the DOCUMENT on rather than dying in a nested scroller (an Android trap is caused by `contain`, not by the absence of it). The tile keeps its caption ("where a customer asks…") and the "Fictional character · AI-made picture" label below is untouched — the companion is fictional, the chat is real. **One shared-client change:** an untouched thread now stays at the TOP instead of auto-anchoring to the bottom (`anchored = messages.length > 0 || busy || notice || handover`); where the panel is taller than its opening this is a no-op, but in a bounded tile the old behaviour scrolled the sun-dot notice — what the agent may and may not do — out of sight before anyone read it. **Verified** on dev at 412×915 (Playwright, Chrome): one real message in and a grounded reply out ("฿600 per hour with a 2-hour minimum, and her profile lists the Ari area"), the same conversation id and thread after a reload, `?lang=th` renders the composer, header and caption in Thai, `scrollWidth === 412` and the page still scrolls 1,100px past the chat; shots `story-m-card-chat-*.png`. **Still for James (not renamed here):** the chat's own header and greeting still say "ผู้ช่วย / Ploy's receptionist" (`v3.chat.title`, `AI.greeting`) two centimetres under a caption that says Kathy — the naming decision he is already holding.

> **Implementer:** append here as you go. Bringing a well-framed trade-off to the operator is the expected behavior, not an interruption.

- **Mentor audit (2026-09-19, commit `7aa73f6`).** A separate Fable pass read every screen in both languages against one informal test James set: "would my startup mentor make a negative comment?" Copy fixes on seven screens (two-line headlines, no payment rail named anywhere, the money screen's last block reframed as "what ฿499 does not buy", meta/OG description shortened). One **open product inconsistency** found, not fixable in copy: the card screen dissects a self-serve `/c/` card whose one button is LINE and which has no Kathy, while screens 3, 4 and 7 describe asking Kathy and sending a request — today Kathy and the request sheet exist only on companion pages `/p/<slug>`. Decision pending with James (draw the anatomy from a companion page, or state the two shapes honestly).
- **§9.68 review round 3 — James's own text (2026-09-19).** James rewrote the page's spine himself and I set it verbatim in English, writing the Thai natively beside it: screen 1 "Frenday. A friend-date you deserve." with "Find your new buddy in Bangkok's open spaces, anytime, every day."; screen 2's body ("…through social posts, comments, and DM – an untrusted, time-consuming, manual process. Our trusted system offers automation, visibility boost, and scam elimination in one package."); screen 3 "Your Profile Card. Your Request, assisted by Kathy."; screen 4 "Kathy can read and answer, but won't accept requests on your behalf."; screen 5 "One page. Everything they want and need."; screen 8 "This is all you have to know." + "Why not open the real thing now?". Consequences recorded here because they change the page's rules, not just its words. **(a) The headline cap moved from 9 to 14 English words** — his headlines are sentences, so the test now guards line length, not slogans. **(b) The money screen lost the "how Pro is paid" block** (that is `/pro`'s job) and its "what ฿499 does not buy" list became a ROADMAP block, "Not in ฿499 yet — coming": signal-based ranking and visibility boosts, dynamic and surge pricing, market intelligence on what customers will pay, identity and criminal background checks. The old "never" framing is gone, so the test now forbids `does not buy|never` on that screen and requires the label to read as coming — no roadmap item may ever be written in the present tense. **(c) A new optional `footnote?: Bilingual` on `StoryScreen`**, set on the trust screen to "Dynamic pricing coming soon." so "Lower the rate" cannot read as a permanent promise. The copy carries it; the page does not render it yet (the components were another session's file that turn) — an unread optional field is harmless, and the render note is in the handover: one quiet line under the can/cannot object, body-2 size, no asterisk needed. **(d) The sign-in row gained X and a phone handset** (`components/story/signin.tsx`), drawn by us, both in the "next" state with LINE keeping its green and the two new glyphs ink at 70%; seven 44px tiles with an 8px gap fit 412px in one line. Legend: "Email today. LINE, X and phone next." Only email works today and the accessible names still say so.
- **§9.68 review round 4 — the page James can send (2026-09-19).** Twenty items off his fourth Pixel pass; the ones that change a rule rather than a word are recorded here. **(1) No dash on these two routes.** Every em and en dash in a rendered string is a plain hyphen now, including the `<title>` separator on both routes; `content/story.test.ts` fails the build on either character, in the copy AND in the page's own words (`components/story/copy.ts`). The one dash left on screen belongs to Ploy's own headline in `content/providers.ts` — a fictional person speaking for herself, exempt by the same doctrine that exempts her hours. **(2) One inline markup, and one footnote mark, that cannot collide.** A MATCHED pair of asterisks is italic and is spent on exactly one thing, the agent's name inside the screen-3 and screen-4 headlines; a LONE asterisk is not markup at all but the footnote mark ("Lower the rate*" answered by "* Dynamic pricing coming soon."). `storyPickMarked()` keeps the pair for the page, `storyPick()` strips it for everything that cannot hold markup (the title, the description, the OG image), and a five-line `emphasize()` in `story-page.tsx` splits only the headline — so a stray pair in a caption would print its asterisks, which the test forbids. **Italic is a Latin device:** Thai has no italic face and the browser's synthesized oblique smears the loops and the tone marks, so the Thai strings carry the same pair for the same semantic reason and the stylesheet does not slant them (`.sy[lang="th"] .sy-h em { font-style: normal }`). **(5.2) The drawn request button is gone.** Once the chat above it became real (round 3's live tile) the button below it was the one dead control on the screen, at the end of the section, that a thumb would certainly press; making it work would mean inventing a request sheet that belongs to her page, and a request is hers to answer. It is removed and its meaning folded into the chat tile's caption — "Where a customer asks Kathy, and sends a request" — which is where the request is filed anyway. Six callouts became five; `.sy-an-btn` left the sheet. **(5.3 and 6) Three spacings, one rule each.** The "Ploy is fictional…" label and the trust screen's footnote were sitting at the section's 18px rhythm, which reads as a new idea rather than a caption of the object above; both now take `margin-top: -8px` in ONE shared rule (`.sy-cap, .sy-foot`), landing at the 10px a caption sits from its tile inside the anatomy, and the support line under the last button takes the same. The money screen's roadmap heading got the opposite treatment: +14px on top of the rhythm (32px total), so "Not in ฿499 yet but coming soon" stops reading as a fourth line of the ฿499 tile. **(11) The hero.** The first thing a reader saw on a Pixel was the name three times — a big Thai wordmark, its Latin caption, then a headline that opens "Frenday." — followed by a void. The lockup is now `row`, 24px, WITHOUT the Latin caption (small and quiet, said once); the headline is the largest type on the page (`clamp(38px, 11.7vw, 60px)`, Thai a step smaller with its own leading for the marks); the sub-line is 300 weight on `--ink-2` with its own air. The empty paper moved from under the hero to ABOVE it — `padding-top: clamp(112px, 33vh, 290px)` on the first section only — so at 412×915 the hero holds the first viewport and screen 2's headline peeks in at ~810px as an invitation. Still no viewport HEIGHT anywhere (round 2 stands). **(17) A story-scoped provenance label.** The site's `v3.photoAi` ("Fictional character · AI-made picture") is untouched for the rest of the app; this page now prints `STORY_UI.photoAi`, which names the person the tiles just drew — "Ploy is fictional; the picture is AI-made" / "พลอยเป็นตัวละครสมมติ ภาพสร้างด้วยเอไอ" — with `{name}` filled from the record, never a literal, because which demo companion the page dissects is a content decision in `app/story/page.tsx`. **(19) One support address.** New `content/contact.ts` exports `SUPPORT_EMAIL = "hello@frenday.xyz"` (EDITORIAL-RULES rule 7) and the last screen prints it as a quiet `mailto:` under the button. The LABEL is bilingual copy (`StoryScreen.contact`), the ADDRESS is the constant the page joins to it — which is how a Thai line ends up carrying Latin letters at all, and that joined line is the ONE documented exception to "Thai never carries Latin" (named as such in the test). **Copy, in his words:** screen 3 "…assisted by *Kathy*." and "our AI agent"; screen 4 "but don't accept", "{kathy}, our AI language model, is bound to…", "as provided", "06:00 to midnight" (no "24:00" on these routes), plus a fifth Kathy-can line — "Answer basics about the third place, venue or area the companion chose" / "เธิร์ดเพลส" — which raised the can-list cap from four to five; screen 5 "what a customer needs to decide"; screen 6 "The card is free forever." and "Not in ฿499 yet but coming soon"; screen 7 "Live in Bangkok since September 2026." (present tense: it IS live, and the test now forbids "launch" in that headline); screen 8 "That's everything you need to know." + "Shall we see the real thing now?". **American English** (EDITORIAL-RULES rule 6) is swept through this scope and guarded by a British-spelling regex over the copy AND the page's words ("labelled" → "labeled" was the only hit). **Verified** on dev at 412×915, both languages, every screen, plus night (`?at=02:00`) and 1280×900: `scrollWidth === innerWidth === 412`, zero `<hr>`, zero em/en dashes outside Ploy's own record, zero "24:00", exactly two `<em>` (the name, once per marked headline), the `mailto:` resolved, and the only bordered element inside `.sy` is the chat's own composer input (a control, not a divider). 485 app tests and root typecheck green; shots replaced in `apps/paiduay/docs/screenshots/story/` (the five chat shots kept).
- **§9.69 — Editorial rules: singular they, Customer/Companion, hours 06:00–24:00, the agent has a name (2026-09-19, James-ordered, permanent).** Four rules now bind every public-facing string in `apps/paiduay` — site copy, `/story`, share-image text, the AI prompt and its few-shots, launch-kit strings. They are written down once in **`apps/paiduay/docs/EDITORIAL-RULES.md`**, quoted in root `CLAUDE.md`, and enforced by **`apps/paiduay/content/editorial-rules.test.ts`** over every platform copy object (the fictional demo people in `providers.ts`/`cards.ts` are exempt: they speak for themselves and their own rules may be stricter — Nan still ends by 21:00). **(1) Singular they.** A user of either side is they/them/their in English, never she/her or he/him, although the demo companions are fictional women with names; Thai uses the role noun or no pronoun and never เธอ. The system prompt states the rule to the model too, because a nickname like พลอย otherwise pulls it into "she". **(2) The two sides have fixed names:** Customer / ลูกค้า and Companion / เพื่อนเที่ยว — "seller", "provider" and Thai "คนรับงาน"/"คนจ้าง" are gone from public copy (18 Thai occurrences replaced). **(3) Operating hours are 06:00–24:00**, not 06:00–21:00. This is not only copy: `lib/daylight.ts` `BAND_END` 21 → 24 (eighteen band cells, mirrored by `--band-end`/`--band-cells` in `app/globals.css`), `lib/companion-module.ts` `PLATFORM_HOURS.end`/`PLATFORM_END_MIN` (the request tool), and through `BAND_END` the request form in `app/p/[slug]/actions.ts` and the pickers. Two consequences were decided here: **night** (`lib/moment.ts` `isNight`) now means 00:00–06:00, because night is defined as "outside the platform window" and that is what the window became; and `momentsFor`'s `lateWithin` default moved 3 → 6 so "it is getting late, show tomorrow too" still starts at 18:00 rather than sliding to 21:00. **(4) The agent has a NAME, one constant** — `content/agent.ts` `AGENT_NAME` = Kathy / แคธี, re-exported by `content/story.ts` as `KATHY`. Every chat label, notice, portal line and prompt sentence is now built from it (`${AGENT_NAME.th}`), so a rename is one edit; no public string spells the name literally. **Plus: "daytime" is gone** (James, same day) — the hours run to midnight and the lead line is "anytime, every day", so no public string may say daytime / daylight / กลางวัน. The home tagline is now "Company by the hour, every day, 06:00 to midnight" / "เพื่อนเที่ยวคิดเป็นรายชั่วโมง ทุกวัน หกโมงเช้าถึงเที่ยงคืน" (James may refine it), and the venue rules — open, everyday places, no bars, clubs, homes or hotel rooms — are untouched: the window got longer, the product did not become nightlife.
- **§9.70 - Frenday design wave 1 (2026-09-24, ten parallel file-owned agents; integrator commits and deploys).** James's six standing wishes (status §6: adversarial tests, Kathy prompt and tools, Gemini, em dashes, deploy speed, responsiveness) plus a design pass were run as one wave: D1 design system, D2 home/sell/pro, D3 companion page and card, D4 browse, R5 story desktop, K6 Kathy, T7 battery, P8 deploy, O9 onboarding, C10 production critic. Every claim below is from the agents' reports (`scratchpad/wave1/*.md`); what a report did not verify is marked. Nothing here changes the doctrine (the AI requests, a person confirms; ground truth only; the label on every generated picture).
  - **(a) Design direction and system (D1).** Decision: refine INSIDE the existing identity (Pridi names at poster size, the person's paper, the hour as type), not replace it. `app/globals.css` gained an appended, additive "THE FOUNDATION" block: a 14-step type scale (`--t-fine` to `--t-hour`), spacing `--sp-1..8` plus `--sp-section`, radii `--rad-tile/field/card/sheet/pill`, `--shadow-sheet`, motion `--dur-base/--dur-open` (zeroed under reduced motion), state washes `--wash-hover/--wash-press`, `--ring`; the shared v3 primitives now name those tokens at identical values (no visual change); `--max` 720 from 1100px; one focus ring on every v3 surface via `:where` (the surface's ink; primitives with their own ring still win); hover, active, disabled and `aria-busy` on `.v-cta`, hover and active on chips, inputs, days, rows, doors and tiles. No existing token was redefined. The signature is **"the sun marks now"**: `.v-sun`, a sun disc beside the Bangkok time in the chrome on every page, and a `.v-dayb[data-today]` hook in the day strip (inert until `home-picker.tsx` sets the attribute). `ChipButton` gets an invisible 6px halo so the 32px chip has a 44px hit area. Brief: `apps/paiduay/docs/design/DESIGN-SYSTEM-2026-09-24.md` (identity, tables, component inventory, photo and motion rules, page-by-page fix list). **Two bolder directions were drawn as static mockups and NOT applied:** `docs/design/mockups/direction-a.html` (the hour as the page) and `direction-b.html` (the poster wall), James's call. Verified: typecheck, plate tests 5/5, 27 audit shots (9 pages at 390/820/1440) plus 4 retakes, no horizontal scroll, 0 em dashes in the brief. Open: keep or quiet the ink language disc (James); the ink focus ring now reaches the internal surfaces too (intended, admin owners should glance). The fix list for files D1 does not own is section 9 of the brief (tokens in browse, card, sell, pro and story CSS; `/p` not-found from v1 `Shell` to v3 `Chrome`; Clerk appearance variables; Thai numerals and arrow-free labels on `/pro`, copy is James's).
  - **(b) Home, /sell, /pro: one job, one action (D2).** Home foot rebuilt as `components/home/home-foot.tsx`: ONE seller door (to `/sell`, the page James tweets) instead of the `/join` door plus a `/sell` line; halves side by side from 720px. `/sell`: "Make your card" on the first screen, kicker cut, the specimen plate replaced by the REAL `CardTile` that `/browse` draws, steps and price share a row from 720px, 1120px wide from 960px (was a phone column in a void). `/pro`: the head carries the complete four-item list, a new section 3 for the link-name change, the grey schematic strip replaced by the real `/browse` label and tile, words left and proof right from 960px. New keys in `content/copy.ts` (Thai first, English equal, American spelling, they/them). **The ฿499 truth, read from code:** (1) every kit template incl. night versions (`lib/pro/entitlement.ts`), (2) three clips (`app/c/[handle]/kit/video/route.ts` answers 402 unless Pro), (3) one link-name change (`lib/cards/repo.ts` `validateRename`, 90-day forward), (4) a row on `/browse` labeled "Pro · paid placement" (`app/browse/page.tsx`). `/pro` and `/sell` now say all four; the handle illustration and the row proof are captioned as illustrations (Mint has not bought Pro). **Story mismatch, not edited:** `content/story.ts`'s money screen lists the kit, one handle change and the directory row and omits the three clips. James's words, James's decision. Verified: typecheck twice, editorial-rules 12/12 twice, shots at 390/820/1440 in both languages; two 820 defects fixed and re-shot (pro head doors stacked until 960px, sell EN title clamped to `clamp(40px, 6.5vw, 68px)`). Not shot: the day state (`?at=10:00`); 820 Thai after the two width-based fixes. Open: `/pro` at 390 is longer (4772 vs 3628 px) with everything a reader needs still in the fold; the `/join` door is gone from the front door (one href in `home-foot.tsx` if James wants it back); `CardTile` hard-codes `?s=direct` so hops from `/sell` and `/pro` lose attribution (a `source` prop would fix it); `copy-sell.ts` `sell.pro` is now unused, `copy-pro.ts` `unlockHandle` says "handle" where the public pages say "link name", `copy-pro-public.ts` `metaDescription` omits the link-name change and the "หนึ่ง · / สอง · / สาม ·" numbering is split across three files.
  - **(c) Companion page, card and chat sheet (D3).** Provenance on the plate: a fictional person's card (`/c/mint`) now prints `v3.photoAi` ("ตัวละครสมมติ · ภาพสร้างด้วยเอไอ" / "Fictional character · AI-made picture") ON the plate, one language per line, the same string the dynamic page prints; before, the card's photo carried no AI label at all (the one doctrine gap, and the biggest `/p` vs `/c` parity gap). From 720px the plate runs to the fold (`aspect-ratio: auto; height: min(100dvh, 62vw)`, still sticky) instead of a 4:5 tile floating over empty paper. `/p/[slug]` reads in two columns from 1024px (new `components/companion/read.css`: bio, list and areas left at a 36em measure; week table, rate, rules and languages right; foot spans); below 1024 nothing changes; 1440 page height 2974 to 2224. Chat sheet hardening in `components/chat-client.tsx` (props, API contract, polling and `frame="bare"` unchanged): header and composer `shrink-0` with a hairline, the thread `min-h-0 overscroll-contain`, so a collapsing URL bar shrinks the thread and never pushes the header over the first bubble; bubbles capped at `min(82%, 36em)` with `overflow-wrap: anywhere`; the rejected-send notice is a left-aligned outlined system line with the sun dot (the draft still restored); the typing dots carry the assistant tag when the last message is the Customer's; input 16px (no iOS zoom), `enterKeyHint="send"`, `autoComplete="off"`. **The real-phone clip was NOT reproduced in headless Chrome; the fix is structural, not a verified repro.** Verified: typecheck, 28 tests, shots at 390/820/1440, five-second test 5/5 on `/p/ploy` and `/c/mint` (was 4/5 without the label). Not done: kit gallery visuals (`components/kit-gallery/**`, 3/5 with cold placeholders); `card-paused.tsx` keeps a plain `<img>` (a paused card says nothing but its name). Requests: Mint's bio in `content/cards.ts` still says "daytime" / "กลางวัน" (a demo person's own words, exempt, but it contradicts the hours line on the same card); `story.css` `.sy-an-chat` height cuts the chip row at 390 (60dvh or a 360px floor would show all four).
  - **(d) /browse as a timetable (D4).** The deck is a departures board: rows grouped by the day each Companion is next free ("ว่างวันนี้ / ว่างพรุ่งนี้ / ว่างวันเสาร์"), one label per group; every row draws the SAME 06 to 24 axis at the same x with the sun tick at now running down the whole "today" group (`components/browse/band.tsx`: `Band` with gone hours dimmed, `Scale` once per group, marks within 2.5 h of now step aside); portrait at the plate's own 4:5 crop (96/104/120 px) with the AI caption as the figcaption split at its own dot; who / when / price / where in every row; "ว่างตอนนี้ ถึง 20:00 / Free now until 20:00" in the sun ink when inside their hours and the minimum still fits. A person's hue lives only in their name, monogram and free spans; the paper stays neutral. Pure `groupByDay` + `sortKey` in `app/browse/deck.ts` (3 new tests); the sort order is untouched (soonest free first, minHours-aware). Page height at 390: 7,904 to ~3,050 px (cards section from ~6,600 to ~2,300); 1440: 8,729 to 3,111. Five-second test 2/5 to 5/5. **§9.62 (b) upheld: the cards section still sits BELOW the deck**; a jump link "ดูการ์ด / See the cards" beside the heading makes it one tap and `id="cards"` is kept for the home door; the critic's "move it above" was not needed once the deck was short. The timetable's 10 bilingual strings live in `components/browse/copy.ts` (`BROWSE_COPY`, not yet swept by `editorial-rules.test.ts`). Verified: typecheck (one unrelated error in T7's in-flight `scripts/battery/run.ts` at 22:07, gone by T7's own check), 20 tests, live TH/EN with `?at=&day=` and `?a=cafe`, night at 02:00 inverts through the tokens with no rule of D4's. Open: the band shows only the next free DAY (a week strip per row was out of the time box); `:nth-last-child(1 of span)` and `:has()` degrade cosmetically on old browsers; R5's audit (shot while D4 was editing) saw the 820 day label colliding with the axis and a mostly empty 1440 track, re-audit after merge.
  - **(e) /story desktop treatment and the tablet/desktop audit (R5).** ONE left rail for everything: each section's children are wrapped in `.sy-text` / `.sy-visual` / `.sy-text-after` divs in the same DOM order, `display: contents` below 1024px, so **the phone is pixel-identical** (PIL diff at 390: 1 pixel, a transition frame; page height 6431 unchanged). Tablet 720 to 1023: one 640px column, hero 60 to 72px. Desktop from 1024: a 1240px document; the five visual screens (card frames, can/cannot, Ploy's page, money, today) are `grid: minmax(0,9fr) minmax(0,15fr)` with the text column sticky at `top: 72px` (a position, not a height), the four text screens stay one column on the same rail (a centered essay column would have made the left edge jump between screens); hero up to 84px, body 19px/33em (Thai 17em); Ploy's page becomes a spread (who / does / pair beside the LIVE chat, 420 to 560px tall); the roadmap on one full measure. No viewport heights, no lines, no dashes, no motion added; `content/story.ts` and the chat, anatomy and frames components untouched. Verified: typecheck, 64 tests, shots at 390/820/1440/1920 in both languages. **Audit, report only (820 and 1440, EN, taken 22:07 to 22:09 while other agents edited):** home 1440 is exactly one viewport with ~150px of empty paper under the footer, home 820 wraps the header to three lines and breaks "Auntie Nok"; sell and pro squeezed their top grids at 820 (D2 then stacked both until 960px); browse 820 label collides with the axis; `/p/ploy` and `/c/mint` read as intentional; the sign-in 1440 shot did not land. The story-chat sticky header clipping a phone's first bubble (status §3) was NOT reproduced at 390 or 820; the desktop chat box's greeting running under the composer while unread is `chat-client.tsx` (D3's hardening above). Ploy's own headline keeps its em dash and "favours" (`content/providers.ts`, exempt by doctrine).
  - **(f) Kathy: prompt v2, tool truth, the `system` hook (K6).** `content/ai.ts` restructured, same doctrine, still one static string (cache-friendly prefix, no clock): identity, then REPLY SHAPE (1. THE OPENER is literally the first characters of the reply, in the customer's language, once per conversation, never repeated; **a greeting or "ขอบคุณค่ะ" in front of the opener is no longer allowed**, the likeliest cause of the "sentence before the opener" failures, so if James wants สวัสดีค่ะ on a first message it goes after the opener; 2. LANGUAGE decided from the latest message alone, every turn; 3. LENGTH; 4. PUNCTUATION: comma, period or hyphen, the em dash out, guidance not a hard rule, per §6.4), PRONOUNS, GROUND TRUTH, new DATES AND TIMES (resolve from the tool's `today`/`upcoming`, say the resolved date back with its weekday, the past rule, the 06:00 to 24:00 window, never call the tool with a slot known to fail), BOUNDARIES AND REFUSALS with five named patterns and redirects (nightlife, private place, romance, direct contact, off-profile and other companions), SAFETY, BOOKING (`openRequestInThisChat` replaces `get_bookings` as the one-intent-one-request fact), four short examples. Exported `OPENER`; 16 tests in `content/ai.test.ts` pin the opener as first rule and first characters, the em-dash count (exactly one, the guidance line itself), the refusal patterns and forbidden acceptance words in the examples. **Tool truth (nothing new the model can DO):** `get_companion_profile` returns `rate.canonical` (rate, minimum, minimum total, paid to the Companion at the end), `listedHours` per weekday in calendar order ("none listed" for a day off), `howToReach` (no direct contact exists, stated as fact), `today`, `upcoming` (the next 7 dates with weekday and listed hours, so "this Saturday" is read off, not computed), `openRequestInThisChat` (an enrichment that never fails the profile); `request_meetup` returns `weekday`, its "past" error names now-in-Bangkok and its "window" error names which end was crossed. **WO-7 hook built, tuning not run:** `ChatRequest.system` may be a string OR `(target: ModelTarget) => string`, resolved in `KernelGateway.chat` after adapter and model selection so `AIContext.model` overrides are honored (`packages/core/src/ai/gateway.ts`, `llm.ts`, `system-variant.test.ts`); `systemFor(target)` in `ai.ts` = base + `PROMPT_ADDENDA[model] ?? PROMPT_ADDENDA[provider] ?? ""`, Anthropic byte-identical to `AI.system` (test-pinned), so the production prefix does not move. Not wired: `packages/modules/scheduled-interactions/src/chat.ts` still composes a string from `prompts["webchat.system"]`, so nothing changes for any provider until it hands the kernel a function; its millisecond clock line still defeats prefix caching (WO-1, deferred, not started unsolicited). Verified: root typecheck 17/17, 44 + 17 tests. **Live spot check** on Haiku 4.5 through a scratch HTTP shim on :3402 (Next 16 refuses a second dev server per app dir; same kernel, DB and tenant), 6 messages, about $0.10: 5 pass, 1 partial (the Thai mid-chat switch got the language, the resolved date and no opener repeat right, then used "เรียบร้อย", a forbidden word, and mangled พลอย to เพลอย); three relative dates resolved correctly, nightlife, deposit and direct-contact refusals held, 0 em dashes in 6 replies. Two scratch threads remain in the shared dev DB (`p_ploy_3621850d…`, `p_ploy_35550396…`); no meetup record was written.
  - **(g) Deploy pipeline (P8).** `.dockerignore` rewritten (docs, screenshots, `.data`, tsbuildinfo, launch logs, archives, `.tmp-*`, root md/html/txt, tests, editor dirs, deploy runtime files): build context ~966 MB to ~30 MB; nothing under any `docs/` is imported at build time (grep across apps and packages, 0 hits). `deploy/Dockerfile` is multi-stage: `manifests` (lockfile plus every workspace `package.json`, found automatically) then `deps` (`pnpm install --frozen-lockfile` with a BuildKit cache mount for the pnpm store, `--package-import-method copy`; ffmpeg AFTER the install so the install layer is shared by every app) then `build` (`COPY . .`, `pnpm --filter $APP build`; **`NEXT_SKIP_TYPECHECK` default 1**, `NEXT_STANDALONE=$STANDALONE`) then `runtime-0` (today's full-workspace image, `pnpm --filter $APP start`) or `runtime-1` (Next standalone: `.next/standalone`, `.next/static`, `public`, `assets`, `node server.js`) selected by `FROM runtime-${STANDALONE}`, **global ARG default 0**. `apps/paiduay/next.config.ts`: `typescript.ignoreBuildErrors` only under `NEXT_SKIP_TYPECHECK=1`, `output: "standalone"` only under `NEXT_STANDALONE=1`, `outputFileTracingRoot` always the repo root. `deploy/compose.yaml`: per-service `PORT` and `STANDALONE: "0"`, paiduay `NEXT_SKIP_TYPECHECK: "1"` and `STANDALONE: ${PAIDUAY_STANDALONE:-0}`, ops pinned never-standalone. `deploy/deploy.sh` rewritten: detached via setsid + nohup, timestamped log `/tmp/ai-factory-deploy-<stamp>.log` with a `-latest.log` symlink, flock, `git pull --ff-only`, env copy as before, optional `MIGRATE=1`, `docker compose build <svc>` then `up -d --no-deps <svc>`, waits up to 180 s for HTTP 200 (paiduay: `https://frenday.xyz/story`), prints elapsed, `NOTIFY=1` posts to the n8n webhook on success and failure, refuses any service outside demo/clinic/barber/cameo/paiduay, never prunes; `--all` keeps the old behavior. New `deploy/push-and-deploy.sh` (Mac): `git push vps main`, ssh pull, start the detached deploy, tail the server log until the verdict (`--no-watch`). Doc: `apps/paiduay/docs/DEPLOY-FAST.md` (timings, first-deploy commands, standalone test recipe with a throwaway container, rollback, the off-VPS end state). Verified locally: `NEXT_SKIP_TYPECHECK=1 next build` exit 0 in 96 s with "Skipping validation of types"; `NEXT_STANDALONE=1` build exit 0 in 72 s with `.next/standalone/apps/paiduay/server.js` and 46 MB of traced `node_modules` (sharp, pg-boss); the dev server answered 200 before and after; `bash -n` on both scripts. VPS read-only: Docker 29.7.2, Compose v5.5.0, buildx 0.36.1 (cache mounts and global-ARG FROM supported), paiduay image 3.66 GB, build cache 65 GB (57.9 GB reclaimable, shared with Luna, TuaTon and chatbot-engine), disk 78 % full, checkout clean at `9631f44`. **NOT verified: the Dockerfile was never built (no Docker on the Mac) and the standalone runtime never ran; the first deploy is the test.** Expect the first build slow (fresh install layer, empty cache mount, 5 to 7 min) and the second source-only deploy in 3 to 4 min (the 3.6 GB export/unpack only shrinks with standalone). Standalone stays OFF: `lib/photo/store.ts:123` does a dynamic `stat`, so the tracer includes the whole app dir (fix: `stat(/*turbopackIgnore: true*/ p)`). The four demo apps' `next.config` do not honor `NEXT_SKIP_TYPECHECK`. James's calls: the off-VPS builder (GitHub Actions + GHCR, or the n8n VPS + a registry) and which credentials may be created; pruning the builder cache (`docker builder prune --keep-storage 20G`); flipping paiduay to standalone after the throwaway test.
  - **(h) Seller onboarding (O9).** Walls: `/sign-in` and `/sign-up` are framed by `WallHead` (kicker "ทำหน้าของคุณเอง", lede "free, about three minutes, only an email and a password", one sentence of what Frenday is; sign-in points at "no account yet") and `WallFoot` (the three next steps incl. the email code, "the account never appears on your page", the support address); strings in `components/onboarding/copy.ts` (`ONBOARDING_COPY`, not yet swept by the editorial test); the Clerk form, `path`/`routing`, `redirect_url` handling and the F1 no-loop rule untouched. `/me` is a dashboard: per card one state sentence and ONE next action (draft: Continue; live: Copy + Share via `share-link.tsx`, share sheet where the phone has one; paused: Reopen with a hint), quiet doors (View, Edit, Images), a Pro line (Pro until date, or Free plan with the "฿499 once for 12 months" door to `/c/<h>/pro`; an expired Pro says so), numbers collapsed under a published or paused card, hidden cards keep the S6 honest line, the three-card limit stated instead of a fourth door, zero cards = lede + "Make my page"; data access unchanged. Editor: create mode shows "ข้อ n จาก N / Step n of N" under the bar status (**a recorded deviation from `editor.css`'s "no step counter" header**; a count, not a bar; one line to revert), the "Other channels" ask is titled optional, one hint under the handle ask (first the link, next photo, name, price and LINE). Persona (24, Thai, Pixel, referred by a friend): taps to a live card flat at ~17 plus the picker (no asks removed; the data contract is not O9's), moments of not knowing what is happening 5 to 0 by the agent's read. Verified: typecheck, 27 tests, curl probes (`/me` and `/c/new` 307 to the walls with `redirect_url` and `lang` carried, walls 200), before/after shots of the walls at 390/1440 in both languages. **NOT verified: signed-in pixels.** The CDP walker (`scratchpad/wave1/o9/walk.mts`, `+clerk_test` address, code 424242) never got past `Clerk.loaded` (or Clerk's bot protection) in three runs, so `/me`, `/c/new` and the editor were reviewed from code and the preview component only; a dev user `o9.wave1+clerk_test@example.com` may exist on the DEV Clerk instance (delete from the dashboard); nothing touched production. Open: `/me` cannot say "slip received, waiting" (the pending request lives in `lib/pro/requests.ts`, a one-line read); `/join` is the older door, off the `/sell` path, candidate for a redirect to `/sell`; the walls and `/me` say "หน้า" where the editor says "การ์ด"; `app/me/copy.ts` keeps the old wall strings (append-only).
  - **(i) Adversarial battery (T7).** Results 24 Sept: Gemini flash-lite 28/54 pass (72 messages), Haiku 4.5 19/29 demo-weight pass (39 messages); Gemini copies the Thai example lines to English customers; both skip the opener on refusals; prod grounding verified clean of the old name and address (the battery leak came from the dev DB). `apps/paiduay/scripts/battery/`: 17 machine-checkable assertions in `assert.ts` (opener-first, language, no forbidden yes with a negation window, no EN pronoun, no เธอ, no service-name leak, no contact leak, no payment details, no system-prompt leak, refuses, filed / not filed, no question, short, contains / not contains / not matches; 11 unit tests), 54 scenarios / 72 messages in 15 categories, th/en/mixed, each with a judge rubric and a demo weight (`scenarios.ts`), an HTTP-only runner against `POST /api/chat` with a spend guard (`--max-messages`, three-consecutive-error abort, budget-denial abort, 2.5 s pacing, `--dry-run`, `--only`, `--judge` writes a judge packet without a model call), a markdown + JSON reporter ranking failures by demo weight; `pnpm --filter paiduay battery` and `battery:test`. Reports land in `apps/paiduay/trust-reports/battery-2026-09-24-<model>.{md,json,judge.json}`. **At the integrator's cut: runner built, baseline partial.** The Gemini flash-lite run (72 messages) and the Haiku run (39, capped at 40 by the coordinator) were in flight and `trust-reports/` was empty; the raw logs (`scratchpad/wave1/t7-gemini.log`: 28 ok / 22 fail so far; `t7-haiku.log`: 27 ok / 10 fail so far) already show opener-first, no-deposit and after-midnight-not-filed failures on both models, to be read against the finished reports.
  - **(j) Critic baseline of production, before the wave (C10).** `apps/paiduay/docs/design/CRITIC-BEFORE-2026-09-24.md`: five-second tests at 390 (home 2/5, browse 4/5, sell 4/5, pro 4/5, story 2/5, card 5/5, page 4/5, kit 3/5), five persona walks, a generic-tell audit with "the one thing no template has" per page, a trust audit, a cross-page consistency table (Pro price consistent; ฿499 contents, request vs book, card vs page naming and who-answers all in conflict) and ten ranked changes. Addressed in tonight's dev tree: the browse fold (D4), the AI label on the card (D3), the ฿499 list on `/pro` and `/sell` (D2), the `/pro` 820 grid (D2). **Still open after the wave:** (1) production served the bare Next 404 to a burst of five concurrent headless loads at 22:02 (65 of 66 shots, black, unbranded) and rendered the same URLs fine one at a time from 22:06; the integrator's curl reproduction did NOT trigger it, cause unknown (a limiter answering with `notFound()`, middleware, Caddy or Clerk under concurrency), and a branded `not-found.tsx` is missing regardless; (2) `/story` paragraph 2 ("automation, visibility boost, and scam elimination in one package") fails the a16z bar, and (3) its roadmap (boosts, surge pricing, market intelligence, background checks) contradicts `/pro`'s "no boost, we verify no one", both James's words; (4) **two funnels described as one product**: `/sell` says "messages you directly, no one in between" while `/story` says "Kathy sends the request", the LINE-direct card vs the Kathy-mediated page; (5) the home fold at night is an empty state ("Nobody is free at this hour") and at 1440 the whole home page is one; (6) "book" on the card and the browse tiles, "request" everywhere else; (7) EN rule breaks: "favours" (Ploy's own line), "Daytime" on Mint's bio, "don't accept" grammar on `/story`, "600baht an hour", Auntie Nok's 05:30 before opening; (8) the kit ships as placeholders; (9) **Kathy is invisible on home and `/browse`**, met only on `/p/*`, `/story` and `/policy`; (10) **five nouns for two things** (card / page / profile / directory / everyone). Keep: browse's poster grammar, the card's one fold and one button, `/pro`'s "shown rather than claimed", `/policy`'s candor, `/p/ploy`'s first-person rules, the clock and toggle in the same corner everywhere.
  - **Not verified tonight, in one place:** signed-in onboarding pixels (`/me`, `/c/new`, the editor); the Docker build itself and the standalone runtime (the first deploy through the new pipeline is the test); the real-phone chat clip (structural fix only); the day state of the new home, sell and pro pieces; the battery baseline (partial, reports pending); the 404 burst (not reproduced). Next: the integrator commits the wave, deploys with `deploy/push-and-deploy.sh`, verifies live, and the finished battery reports are read.

- **9.71 Frenday wave 2 + Pro templates (2026-09-24, 22:40 to 23:25).** Twelve Fable agents plus one Opus research agent, file-owned, integrated in one commit.
  - **(a) Pro card page templates (P1).** `Card.template` (`classic|poster|editorial|compact`) and `Card.look` (`accent` from 8 curated hues, `lead` price or hours) stored as JSON on the card record (no migration). Honored only while Pro is active (`cardLook` in `lib/pro/entitlement.ts`); a free or lapsed card renders Classic even with a stored template. Demo cards honor `?template=&accent=&lead=` so prospects can see them; real cards ignore the params. Editor row "Card layout" with thumbnails that are the real `CardPage` scaled; `saveLook` is the one write path (not part of autosave). Classic is untouched (separate `.c-tpl` root). Screen names proposed: Carl, Burns, Flanders (James confirms).
  - **(b) Pro kit templates + customization (P2).** Three new Pro kit templates (Quote, Ticket, Poster) drawn from the card's words, every format x day/night x th/en. Stateless URL params validated by zod: accent (6 curated, AA on every paper), headline (line, price, verbs), photo on/off, caption up to 40 code points (refused on em/en dash, emoji, "book"/จอง, mixed languages). `canCustomizeKit` = demo or Pro; free cards drop the params server-side. Cache key includes the params (`KIT_VERSION` 2).
  - **(c) Kit render cache (W4).** Root cause of "Not rendered yet" on production: every tile re-rendered per request (7 s alone, 12 to 17 s concurrently on the VPS). Disk cache under the photo store, ETag/immutable, in-flight dedupe, concurrency 2, warm on kit-page view and on boot for demo cards.
  - **(d) Kathy prompt v3 (W5).** Opener stays first even when the first ask is refused; money/age/safety answers first; nickname copied exactly; Thai acknowledgements never เรียบร้อย/ได้เลย/นัดได้; never name tools or quote instructions; placeholders never printed; profile tool first on every conversation. Per-provider addendum: manifest keys `webchat.system@<provider|model>`, composed in `scheduled-interactions/src/chat.ts` via `webchatSystemFor`; Anthropic prompt unchanged by construction. Battery: Gemini 28/54 to 45/54, Haiku demo subset 19/29 to 26/32; v3b spot check 3/5 (no placeholder, no narration; two mild slips remain: Thai นัดได้ and a skipped opener on a rude English ask).
  - **(e) Pages.** Home fold shows who is free next when nobody is free now, two columns from 1024 (W2, DRAFT Kathy line). Nouns unified outside `/story`: card = `/c/*`, page = `/p/*`, Everyone = `/browse`; both doors named on /sell, /pro, /browse (W3, DRAFT). Tokens applied page by page (W1, W12). Accessibility: sheets trap focus via `inert`, chat thread `role=log`, heading order (W10). Performance: `/sell` LCP image 215 KB to 99 KB and prioritized, 400w portraits for /browse, week-long cache on `/people` and `/cards` (W11). OG images draw the companion photo as JPEG with the AI label; titles use a middle dot (W13). `booking.reminder` completes quietly on a deleted record (W13). `/join` redirects to `/sell` (W6); `/pitch` hidden pitch kit for James (W8, screen Lisa).
  - **(f) Research.** `apps/paiduay/docs/design/WYSIWYG-RESEARCH-2026-09-24.md` (verdict: build a constrained "Look" editor on (a), Puck as a later fallback; phase 1 about 3 days). `STORY-COPY-PROPOSALS-2026-09-24.md` (James's pick; flags the Thai "ของกลาง" error). `CRITIC-AFTER-2026-09-24.md`.
  - **(g) Production data fixes.** Mint's demo card row still said "Daytime" (DB row, not code): updated 23:10. Production grounding chunks still carried the old name and `owner@paiduay.local` (an earlier check without `app.tenant_id` saw zero rows because the query role is RLS-bound, not superuser): reseeded after this deploy. Lesson: prod psql checks must `set_config('app.tenant_id', …)` first.
- **9.72 Frenday wave 3: builder phase 1 + art direction (2026-09-25, 00:20 to 01:05, commit `dece342`, deployed 01:01).** Four Opus 5.5 agents, file-owned, on James's order of 00:00 (build the Pro "Look" editor with all research defaults approved; raise the visual bar after he rejected mockups A/B as "amateur").
  - **(a) Look model.** `CardLook` gains `ground` (paper/tint/deep), `order` and `hidden` over the body blocks `about, links, verbs, areas` (`CARD_BLOCKS` default = the classic reading order), and `photoZoom` 1.0 to 1.6 (focal stays in `photo.focal`). Enum ids and clamped numbers only; unknown ids dropped on read (`sanitizeLook`); still JSON on `kernel.records.data`, no migration. Locked elements are not blocks (`components/card/plan.ts` `LOCKED_SLOTS`: name, line, facts, LINE button + id, trust line, labels, provenance, report, footer), so they cannot be hidden or moved. On the three Pro layouts "areas" is a facts row: hideable, not movable. `TEMPLATE_KNOBS` exists (all true in phase 1).
  - **(b) Gate.** Deviation from the P1 rule "saveLook checks Pro first": free users may now SAVE a look (James's approved default "free users may preview Pro looks on their own card"); `cardLook` still shows classic publicly until Pro is active. Demo cards accept `?template= ?ground= ?order= ?hide= ?zoom=` for reviewers; real cards ignore query overrides.
  - **(c) Editor.** The "หน้าตา / Look" bottom sheet (`components/editor/look-sheet.tsx`, `photo-frame.tsx`, `look-draft.ts`): Layout (real-card thumbnails), Color (8 accents + 3 grounds + lead), Photo (one-finger pan, zoom slider, arrow keys), Order (up/down + show/hide, locked rows listed). Preview draws the STORED look; debounced `saveLook(handle, {template, look, focal})` 600 ms; undo per gesture. No drag (phase 2). Screen name Frink (PROPOSED, `SCREENS.lookSheet`).
  - **(d) Art direction.** `apps/paiduay/docs/design/ART-DIRECTION-2026-09-25.md`: named bar (Monocle city guides; Transit / departures board; Airbnb Experiences host pages), ten checkable criteria, diagnosis (layout, not identity: 11 text sizes per screen, underlined name lists, empty half-screens, small faces), per-page briefs. Applied to the foundation (grid + wrap tokens in `globals.css`) and home (bigger portraits, hour bars, other-hours timetable, faces band, tomorrow after 20:00). Home self-score 4+ on every criterion; still short at 1440 with 1 to 2 people free, and the shared header.
  - **(e) Fixes.** Template-aware OG (`lib/og-card.tsx`; Next passes no query to OG, so demo overrides do not reach it); 404 has its own title; " · " title separators; AI label burnt into demo kit images (`KIT_VERSION` 3, video `PLAN_VERSION` 2); em dashes removed from editor copy and demo hooks. The critic's "/story is slow" was a misread log column: `/pro` is the slow page (8 eager full-size kit images); handed to the wave 3b /pro agent.
  - **(f) Verified:** root typecheck 17/17; paiduay 607/607 (two DB tests time out at 60 s on a 275 ms Tailscale RTT, pass with a longer timeout); B-core contrast sweep: weakest body pair 6.15:1 by math, 4.65:1 lowest live text; Look sheet exercised signed in on dev with a throwaway Clerk user (deleted); production pages 200 after deploy. NOT verified: a real phone; the editor on production.
  - **(g) Dev data.** Dev Mint bio "Daytime / นัดกันช่วงกลางวัน" replaced to match production.
  - **(h) Wave 3b, seven page agents (01:10 to 02:30, commit `074a85d`, deployed 02:38).** /browse on the 12-column grid (one left edge, tiles draw the monogram under the photo so never blank); /pro (four empty squares replaced by the two newest real cards dimmed; kit wall grouped Free/Pro; kit thumbnails as ~20 KB WebP from the cached PNG via sharp, `lib/kit/thumb.ts`, `w=360|540` only, lazy with `?v=`: kit bytes 6.43 MB to 0.12 MB); /sell on home's frame; the shared v3 chrome aligned to each page's frame via a "CHROME ON THE GRID" block in `globals.css`, language switch now quiet text "ไทย / EN"; /story visual layer only (no top air, Ploy's real row in the hero from 1024, the three steps drawn with the system's own objects, plain can/cannot rows, real anatomy photo, text switch; the `?lang` switch bug fixed; `content/story.ts` untouched); /p puts price + both buttons in the 390 fold (deviation from the brief's full-bleed 4:5 photo, which would push the buttons 250 px below the fold: photo 56% wide beside the facts); /c polished across 4 templates x 3 grounds; kit gallery as a contact sheet with a monogram + hairline while rendering; /me, editor and sign-in walls in the tool register. Fixes: kit cache eviction spared preview/custom siblings (a default redraw used to delete them); `scripts/shot.ts` watchdog (`SHOT_TIMEOUT_MS`, default 120 s) after 56 hung headless Chromes pushed the Mac to load 95 and the dev server to 12 min per page. Verified: root typecheck 17/17, paiduay 610/610, every public page 200 in about 1 s on production. Self-scores 4/5 per page; the independent critic's score is in `CRITIC-WAVE3-2026-09-25.md`.
  - **(i) Critic and wave 3c (02:45 to 04:10, commit `d1ba297`, deployed 03:59).** An independent Opus critic scored production after 3b at ~60% of the bar (site average 3.6, versus 4.0 self-scored by the page agents: self-scores are not evidence). Root causes were system-level, not per page: 14 size steps, five chrome positions, four caption placements, three day bands, no tabular numerals. Decisions: the size tokens collapse to six steps with the old names re-pointed (no renames across pages); tabular numerals globally with `!important` (the `font` shorthand silently resets `font-variant-numeric`); Thai `line-break: strict` + `.nobr`/`.sep`; ONE chrome rule on the 12-column frame edges (22/40/120 px) replacing per-page offsets; the sun dot leaves the chrome (one sun per screen); photo captions always UNDER the picture at 12px; home's hour bar is the one day band (`.v-dayband`/`.v-dayaxis`, used by /browse, /story, /p's `WeekTable`, which /portal shares). Critic round 2 on production: ~80%, average 4.1, every first screen 4 to 6 sizes. Remaining gap is mostly James's: /story wording, the AI caption length (disclosure wording), the kit hashtags (#RentAFriend reads as hire-a-companion). Wave 3d (labels in sentence case site-wide, one-line captions without changing words, /story sticky words at 1440, kit length, /browse row height, 404 title size) follows.
  - **(j) Process lesson.** Parallel headless screenshot agents leaked 56 Chromes (load 95, dev server 12 min per page); `scripts/shot.ts` now has a watchdog and agents shoot one at a time.

