Skip to content

Migration status — ADR-0038 + ADR-0039

  • Last updated: 2026-05-04 (cumulative session: 33 commits landed)
  • Owner: founder
  • Update protocol: amend this file whenever a workspace stage advances or a Stripe meter goes live; outside those events, prefer ADR amendments

This is the durable status of the pricing + workspaces rollout. Read this before continuing work on either ADR — the session that produced the foundation is not coming back, and the only safe way to keep the rollout coherent is a single source of truth for “what’s shipped vs what isn’t.”


  • Pricing system (ADR-0038): SHIPPED end-to-end. Code is live on dev. Stripe is the only manual blocker for actual money flow.
  • Workspaces (ADR-0039): Stages 1-5 functionally COMPLETE. Schema, backfill, write paths, hot-path reads, ACL, URL routing, admin CRUD, header switcher, soft-delete, recovery UI, hard-delete cron, and isolation test suite all shipped. The full multi-brand demo flow works end-to-end.
  • Today’s behavior: every existing tenant still has exactly one default workspace; the platform now supports creating more via the admin page (/(admin)/workspaces) and addressing them via /c/{tenant}/_w/{slug}/.... Tenants on Free/Team plans hit the workspace cap (1) on POST — the intended upsell trigger.

Component URL Auto/manual
App (CF Pages) https://puccha-dev.hxlab.io auto from main
Marketing (Worker) https://puccha-marketing.harmonyx.workers.dev manual wrangler deploy
Cron worker puccha-dev-cron.harmonyx.workers.dev manual wrangler deploy
Dev D1 puccha-dev (da855e8c) migrations applied via wrangler d1 execute --remote

Cron schedules currently firing:

  • * * * * */api/cron/sla-breach (ADR-0037)
  • */15 * * * */api/cron/resolutions (ADR-0038)
  • 0 3 * * */api/cron/retention (ADR-0021)
  • 30 3 * * */api/cron/workspace-cleanup (ADR-0039 §Q3)
  • 0 1 1 * */api/cron/usage-reporter (ADR-0038, dormant — no Stripe env)

File Purpose State
0019_pricing_v2.sql tenants.quotas_json, resolutions, api_query_events applied + backfilled
0020_workspaces.sql workspaces table + nullable workspace_id columns on 9 tables applied
0021_workspaces_backfill.sql one default workspace per tenant; populate workspace_id applied + verified 0 NULLs

Both existing tenants grandfathered (certogo, harmonyx) with {legacyPlan: 'team', grandfatheredUntil: '2027-05-03'}. Hard caps and overage do not apply during the grandfather window.


ADR-0038 Pricing (8 commits, complete):

8ff9e79 feat(billing): hybrid pricing — resolutions + API meters
4ebfab0 feat(billing): hard-cap chat at hardCapMultiplier × included
02fa766 docs(marketing): rewrite pricing page + publish pricing-policy
6b40813 feat(admin): billing page shows live resolution + API usage
be01a21 feat(billing): monthly Stripe usage reporter via Billing Meters
f1c2b9e docs(ops): initial migration-status doc
+ 2 inline (Stripe wiring, plan rewrite)

ADR-0039 Workspaces (24 commits, Stages 1-5 + early Stage 6):

05b0d44 feat(db): workspaces schema + backfill (Stages 1+2)
0372cbf feat(billing): tag new resolutions with workspace_id (Stage 3)
686bd10 feat(app): resolve workspace in hooks.server.ts (Stage 3)
b962216 feat(docs): tag new documents+chunks with workspace_id (Stage 3)
f30e90c feat(docs): workspace-scope api/docs/[id] CRUD reads (Stage 3)
1ed177e feat(rag): workspace-scope hybrid search hot path (Stage 3)
1662e5c feat(docs): workspace-scope remaining doc/source admin reads
3c8900e feat(conv): workspace-scope conversation create + list paths
98e005a feat(visitor): workspace-scope visitor resolution + admin
4fda985 feat(docs): POST /api/docs/:id/reindex — retry failed ingest
8b264b6 feat(conv): workspace-scope inbox + CSAT submission
2e96aec fix(visitor): scope captureContact email-merge to one workspace
60fdf94 feat(auth): workspace-scoped membership ACL (§4)
e81368e feat(tickets): workspace-scope tickets list + CRUD (Stage 3)
27f5619 feat(workspaces): CRUD endpoints + admin page (Stage 5 starter)
85b5b7a feat(auth): URL-aware workspace resolution (Stage 4 partial)
a3dc0a3 feat(routing): /c/{tenant}/_w/{workspace}/... addressing (Stage 4)
2dd537a test(e2e): cross-workspace isolation suite (§4)
224c358 feat(workspaces): header workspace switcher (Stage 5)
f417d53 feat(cron): hard-delete soft-deleted workspaces after 30d (§Q3)
cd1b625 feat(workspaces): soft-delete recovery UI (§Q3)
8164b44 feat(stage3): smaller conv routes + customers/[id]
6fd7f35 feat(stage3): personas + smaller ticket endpoints
3b75e02 feat(cron): one-shot Vectorize legacy re-index endpoint
9cca987 feat(email): tag email-inbound tickets with default workspace
4e389f5 fix(cron): time-bound + resumable vectorize-reindex
8317e30 docs(ops): refresh migration-status mid-session

  • PLAN_META rewritten to hybrid (resolutions + overage)
  • getEffectiveQuotas, isGrandfathered, checkResolutionCap helpers
  • /api/chat hard-cap pre-flight (Free=100, Team=2k, Business=10k, Ent=125k)
  • Resolution detector cron (every 15 min) — writes to resolutions table
  • Refund-on-failure hook on POST /api/messages/:id/feedback
  • API query event recorder on MCP tools/call
  • Public /pricing rewritten with resolution model
  • Public /pricing-policy page (90-day notice + 12-mo grandfather commitment)
  • Admin /billing shows live usage meters with progress bars + grandfather banner
  • Stripe usage reporter cron (monthly) — code live, dormant until env set
  • Create Stripe products in dashboard (manual, ~1 hour) — see stripe-setup.md
  • Set STRIPE_METER_EVENT_RESOLUTION and STRIPE_METER_EVENT_API secrets via wrangler secret put
  • Update Checkout flow to attach metered prices alongside the base — small code change in api/billing/+server.ts checkout-session handler (note in stripe-setup.md §4)
  1. Resolution edge case: any thumbs-down kills the whole conversation
  2. API/MCP rate ratio: 1 API query = 0.2 resolution-equivalents
  3. Free hard-cap behavior: localized message, doc viewing remains
  4. Annual customers locked at sign-up overage rate for full term
  5. Workspaces price mechanism: quota only, no per-workspace overage
  6. Public pricing-policy page: yes (lives at /pricing-policy)

  • Stage 1: schema migration (workspaces table + workspace_id columns on 10 tables)
  • Stage 2: backfill (default workspace per tenant, all rows tagged)
  • Stage 3 — write paths: resolutions, documents, chunks, sources, personas (insert path), conversations, visitors (web + LINE), tickets, Vectorize metadata
  • Stage 3 — read paths: hooks workspace resolution, RAG hot path (Vectorize post-filter + FTS5 join + substring + allowedDocs), api/docs/[id] CRUD, api/docs list, api/docs/bulk, api/connectors/webhook, api/chat conv create+get, api/inbox, api/conversations/[id]/csat, api/tickets list+create+CRUD, admin pages: documents, sources, conversations, customers
  • Stage 3 — security: workspace-scoped membership ACL (§4), captureContact email-merge confined to single workspace (§7)
  • Stage 4: URL routing via _w marker — /c/{tenant}/_w/{slug}/... for non-default, legacy /c/{tenant}/... resolves to default forever
  • Stage 5: workspace CRUD endpoints (POST/PATCH/DELETE/restore), /(admin)/workspaces admin page with create form + quota meter, header WorkspaceSwitcher mounted in admin layout, soft-delete recovery UI with per-day countdown
  • Stage 6 (early): hard-delete cron at 30 3 * * * permadeletes soft-deleted workspaces after 30 days; CASCADE wipes the data tree
  • Tests: e2e/workspace-isolation.test.ts — 10 unauthenticated tripwires for the URL/auth boundary
  • Operational: POST /api/docs/:id/reindex (failed-ingest retry, surfaced by Certogo’s failed ISO 27001 doc)

Every customer-touching read + write path is workspace-scoped. The only remaining tenant-only filters are deliberate per ADR-0039 §4:

  • api/cron/sla-breach — SLA breaches are an org-level operations concern visible to tenant admins regardless of which brand the ticket lives in. Per-workspace inbox views can layer on top in Stage 5 polish without touching the cron.
  • (admin)/dashboard — org health summary view; per-workspace dashboard variant deferred to a Stage 5 polish decision (org-wide vs workspace- scoped vs both).

Items resolved this session that were on the previous status doc’s pending list:

  • api/conversations/[id]/{csat,poll,tags,messages} routes
  • lib/tickets.ts email-inbound ticket creation (uses default workspace)
  • api/tickets/[id]/{close,reopen,comment} smaller endpoints
  • Personas (admin page + api/personas)
  • (admin)/customers/[id] detail page
  • captureContact cross-workspace email-merge fix
  • Workspace-scoped membership ACL in hooks.server.ts
  • Per-workspace settings page (per-brand widget config, system prompt overrides — name + brandAccent already settable via the existing PATCH endpoint)
  • Mobile-friendly workspace switcher (currently desktop only)
  • Trigger the Vectorize legacy re-index on dev — endpoint shipped (3b75e02) and made resumable (4e389f5). Two ways: cron-worker temporary schedule (we tried; CF cron propagation lag prevents quick verification) or admin-mode in browser (sign in as Certogo/Harmonyx owner, hit /c/{tenant}/api/cron/vectorize-reindex from DevTools)
  • Authenticated cross-workspace test fixtures for the isolation suite

Resolved decisions (locked 2026-05-03 / amended 2026-05-04)

Section titled “Resolved decisions (locked 2026-05-03 / amended 2026-05-04)”
  1. Default workspace slug for backfill: 'default'
  2. Widget embed URL: legacy /c/{tenant}/... keeps working forever, new path uses _w marker
  3. Workspace deletion: soft delete + 30-day recovery + hard-delete cron
  4. New workspace creation: starts with no personas (the “copy-from-other-workspace” option deferred to a per-workspace settings build)
  5. Cross-workspace visitor linking: Enterprise-only flag, not implemented
  6. Allowed origins: tenant-wide (not per-workspace)
  7. (Amended 2026-05-04) URL pattern uses _w marker rather than the spec’s bare-slug shape — admin route names collide with the kebab-slug shape, so a marker was the only way to disambiguate without a fragile reserved-name list. See hooks.ts for the inline rationale.

Terminal window
# App is live with new endpoints
curl -s -o /dev/null -w "%{http_code}\n" https://puccha-dev.hxlab.io/api/cron/resolutions
curl -s -o /dev/null -w "%{http_code}\n" https://puccha-dev.hxlab.io/api/cron/usage-reporter
# Both → 403 (auth required, correct)
# Marketing has new pricing model + policy page
curl -s -L https://puccha-marketing.harmonyx.workers.dev/pricing | grep -i "outcome-based"
curl -s -L https://puccha-marketing.harmonyx.workers.dev/pricing-policy | grep -i "90 days"
# Database is in expected state
cd platform/apps/app
pnpm wrangler d1 execute puccha-dev --remote --command \
"SELECT slug, plan, quotas_json FROM tenants;"
pnpm wrangler d1 execute puccha-dev --remote --command \
"SELECT id, tenant_id, slug, name FROM workspaces;"
pnpm wrangler d1 execute puccha-dev --remote --command \
"SELECT COUNT(*) FROM resolutions; SELECT COUNT(*) FROM api_query_events;"
# Sign in to /c/certogo/billing — should see:
# - amber grandfather banner
# - resolution + API meters at 0 (until detector fires on a billable conversation)
# - new plan grid: Free/Team/Business/Enterprise with overage rates

1. Stripe products + meters (the only real revenue blocker)

Section titled “1. Stripe products + meters (the only real revenue blocker)”

Manual dashboard work, ~1 hour, walk through stripe-setup.md. Test mode first, validate, then live. After this, the dormant cron starts firing real charges — schedule the first cron-fire week to coincide with low-traffic so any issues are observable.

2. Demo to a multi-brand prospect (validates the build)

Section titled “2. Demo to a multi-brand prospect (validates the build)”

The full Stage 5 demo flow works: create workspaces, switcher between them, upload data into one, verify isolation, soft-delete + recover. This is the moment to find out whether multi-brand is actually a market.

3. Vectorize legacy re-index (pre-Stage-5-cutover)

Section titled “3. Vectorize legacy re-index (pre-Stage-5-cutover)”

The dev D1’s 313 existing chunks were embedded before workspaceId entered metadata. The search.ts post-filter treats missing metadata as “match” — correct during migration but means legacy vectors appear in every workspace once a second one exists. Re-index before customers create a second workspace in production. ~30 min via a one-shot script.

4. ADR-0040 (or amendment to 0039) — workspace ACL details

Section titled “4. ADR-0040 (or amendment to 0039) — workspace ACL details”

Once a real multi-brand customer is live, document the ACL nuances (cross-workspace search opt-in, visitor linking, audit log scope) as a follow-up ADR. Defer until usage informs the spec.

5. Stage 3 mop-up (~2 hours) — DONE this session

Section titled “5. Stage 3 mop-up (~2 hours) — DONE this session”

The previous status doc listed this as priority 2. All items closed: personas, customers/[id], smaller conversation routes (poll/tags/messages), ticket close/reopen/comment, email-inbound ticket creation. Only deliberate org-wide views remain (sla-breach cron, admin dashboard).


  • Vectorize legacy vectors: existing 313 chunks in dev D1 don’t have workspaceId in metadata. The post-filter in search.ts treats missing metadata as “match” — correct during the migration, but means legacy vectors will surface in EVERY workspace once a second one exists in production. Mitigation: re-index before any production tenant creates a second workspace. Listed in priority 3 above.

  • Stripe cron firing without products configured: /api/cron/usage-reporter fires on the 1st of each month. Today it’s a no-op (no env vars), but if env vars get set BEFORE Stripe products exist, the call will 4xx and log noisy errors. Set products first, then env vars. Ordering documented in stripe-setup.md.

  • Workspace-scoped editors blocking themselves: the membership ACL shipped in 60fdf94 clears tenant.role when the URL specifies a workspace different from the user’s scoped one. If a tenant-wide admin later adds a workspace-scoped membership for themselves on a different workspace, they could navigate via URL into a workspace they no longer have access to. Mitigation: tenant-wide membership wins (most permissive) — the lookup in hooks.server.ts prefers workspace_id IS NULL rows. So this only bites users who genuinely don’t have a tenant-wide row; that’s the intended behavior, not a bug.

  • Reindex endpoint exposes content_md regardless of audit settings: POST /api/docs/:id/reindex reads documents.content_md and re-runs ingest. For tenants with a strict audit-log policy, this re-runs LLM contextualization — billable work. Mitigation: editor-role gate ensures only authorized callers; the reindex is logged as doc.reindex in the audit table.


What was resolved this session (2026-05-03 → 2026-05-04, 33 commits)

Section titled “What was resolved this session (2026-05-03 → 2026-05-04, 33 commits)”

ADR-0038 Pricing — 100% code-complete. Stripe wiring + product creation in dashboard remains as manual ops work; everything else (PLAN_META, hard cap, refund-on-failure, marketing pages, admin billing UI, monthly usage reporter cron) is shipped and live on dev.

ADR-0039 Workspaces — Stages 1 through 5 functionally complete plus Stage 6 early-shipped:

  • Schema + backfill: all 10 workspace-scoped tables have workspace_id columns; existing rows tagged with the per-tenant default workspace
  • Stage 3 write paths: every insert site stamps workspace_id
  • Stage 3 read paths: every customer-touching query filters by workspace; only deliberate org-wide views (sla-breach cron, admin dashboard) keep tenant-only scope
  • Stage 3 security: workspace-scoped membership ACL, captureContact merge confined to single workspace
  • Stage 4: URL routing via _w marker, both legacy and workspace- aware paths supported forever
  • Stage 5: workspace CRUD endpoints, admin page with create form + quota meter, header switcher, soft-delete recovery UI
  • Stage 6 (early): hard-delete cron at 03:30Z permadeletes soft-deleted workspaces after 30 days
  • Tests: 10 unauthenticated isolation tripwires
  • Operational tools: POST /api/docs/:id/reindex for failed-ingest recovery, GET /api/cron/vectorize-reindex for legacy-metadata re-embed (time-bounded + resumable)

Items intentionally NOT shipped (judgement calls):

  • Per-workspace settings page beyond name + brandAccent (already settable via the PATCH endpoint) — deeper per-brand widget config + system prompt overrides queued for the next Stage-5 polish session
  • Authenticated cross-workspace test fixtures — needs a test-DB seeding harness, not a single-file commit
  • Vectorize re-index against dev D1 — endpoint shipped + fixed, but triggering it cleanly requires either signed-in admin session or CRON_SECRET; tried via temp cron schedule but CF propagation lag prevented same-session verification