---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: completed
stopped_at: Phase 10 context gathered
last_updated: "2026-03-26T05:17:22.329Z"
last_activity: 2026-03-23 — Completed 03-02 onboarding wizard, Slack OAuth, BYO API keys
progress:
  total_phases: 10
  completed_phases: 9
  total_plans: 36
  completed_plans: 36
  percent: 100
---

# Project State

## Project Reference

See: .planning/PROJECT.md (updated 2026-03-22)

**Core value:** An AI employee that works in the channels your team already uses — no new tools to learn, no dashboards to check, just a capable coworker in Slack or WhatsApp.
**Current focus:** Phase 3 — Operator Experience (all plans complete)

## Current Position

Phase: 3 of 3 (Operator Experience)
Plan: 4 of 4 in current phase (all complete)
Status: All 3 phases complete — v1.0 milestone achieved
Last activity: 2026-03-23 — Completed 03-02 onboarding wizard, Slack OAuth, BYO API keys

Progress: [██████████] 100%

## Performance Metrics

**Velocity:**
- Total plans completed: 0
- Average duration: —
- Total execution time: —

**By Phase:**

| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
| - | - | - | - |

**Recent Trend:**
- Last 5 plans: —
- Trend: —

*Updated after each plan completion*
| Phase 01-foundation P01 | 12 | 2 tasks | 32 files |
| Phase 01-foundation P02 | 6 | 2 tasks | 15 files |
| Phase 01-foundation P04 | 19 | 2 tasks | 25 files |
| Phase 01-foundation P03 | 9 | 2 tasks | 20 files |
| Phase 02-agent-features P03 | 7 | 2 tasks | 7 files |
| Phase 02-agent-features P02-01 | 9m 22s | 2 tasks | 15 files |
| Phase 02-agent-features P04 | 5m | 2 tasks | 7 files |
| Phase 02-agent-features P02 | 12m 22s | 3 tasks | 19 files |
| Phase 02-agent-features P05 | ~25m | 2 tasks | 6 files |
| Phase 02-agent-features P06 | 9m 53s | 2 tasks | 3 files |
| Phase 03-operator-experience P01 | 22m | 3 tasks | 20 files |
| Phase 03-operator-experience P03 | ~8m | 1 tasks | 6 files |
| Phase 03-operator-experience P04 | 10m | 1 tasks | 8 files |
| Phase 03-operator-experience P02 | ~35min | 2 tasks | 10 files |
| Phase 03-operator-experience P03 | 8min | 2 tasks | 6 files |
| Phase 03-operator-experience P04 | 10min | 2 tasks | 8 files |
| Phase 03-operator-experience P05 | 2min | 2 tasks | 6 files |
| Phase 04-rbac P01 | 8min | 3 tasks | 14 files |
| Phase 04-rbac P02 | 5min | 3 tasks | 10 files |
| Phase 04-rbac P03 | 8min | 2 tasks | 7 files |
| Phase 05-employee-design P01 | 7min | 2 tasks | 9 files |
| Phase 05-employee-design PP02 | 5min | 2 tasks | 15 files |
| Phase 05-employee-design P03 | 2min | 1 tasks | 0 files |
| Phase 05-employee-design P04 | 1min | 2 tasks | 3 files |
| Phase 06-web-chat P01 | 8min | 2 tasks | 11 files |
| Phase 06-web-chat PP02 | 6min | 2 tasks | 10 files |
| Phase 06-web-chat P03 | verification | 1 tasks | 0 files |
| Phase 07-multilanguage P01 | 7min | 2 tasks | 12 files |
| Phase 07-multilanguage P02 | 9min | 2 tasks | 14 files |
| Phase 07-multilanguage P03 | 45min | 2 tasks | 48 files |
| Phase 07-multilanguage P04 | verification | 1 tasks | 0 files |
| Phase 08-mobile-pwa P02 | 6m 15s | 1 tasks | 12 files |
| Phase 08-mobile-pwa P01 | 7min | 2 tasks | 19 files |
| Phase 08-mobile-pwa P03 | 8min | 2 tasks | 15 files |
| Phase 08-mobile-pwa P04 | verification | 1 tasks | 0 files |
| Phase 09-testing-qa P01 | 5min | 2 tasks | 12 files |
| Phase 09-testing-qa P02 | 1min | 2 tasks | 3 files |
| Phase 09-testing-qa P03 | 3min | 1 tasks | 1 files |

## Accumulated Context

### Decisions

Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work:

- [Roadmap]: Coarse 3-phase structure — Foundation → Agent Features → Operator Experience
- [Roadmap]: Phase 3 portal gated on Phase 2 completing (DB schema stability after memory + tool data models)
- [Roadmap]: WhatsApp Business Verification must be initiated during Phase 1 (1-6 week approval, WhatsApp goes live in Phase 2)
- [Phase 01-foundation]: PostgreSQL RLS with FORCE ROW LEVEL SECURITY chosen for tenant isolation; app connects as konstruct_app role (not superuser)
- [Phase 01-foundation]: SET LOCAL app.current_tenant uses UUID-sanitized f-string (not parameterized) — asyncpg does not support prepared statement placeholders for SET LOCAL
- [Phase 01-foundation]: channel_type stored as TEXT with CHECK constraint — native sa.Enum caused duplicate CREATE TYPE DDL in Alembic migrations
- [Phase 01-foundation]: LiteLLM pinned to ==1.82.5, not latest — September 2025 OOM regression in later versions
- [Phase 01-foundation]: Celery tasks are always sync def with asyncio.run() — hard architectural constraint, never async def
- [Phase 01-foundation]: AI transparency clause is unconditional in system prompt — agents must disclose AI identity when directly asked
- [Phase 01-foundation]: llm-pool port 8004 (consistent with shared/config.py llm_pool_url default, not plan-stated 8002)
- [Phase 01-foundation]: proxy.ts used instead of middleware.ts — Next.js 16 renamed middleware to proxy
- [Phase 01-foundation]: standardSchemaResolver used over zodResolver — hookform/resolvers v5 dropped zod subpackage, uses Standard Schema protocol; zod v4 implements Standard Schema
- [Phase 01-foundation]: Auth.js v5 JWT session strategy chosen — no portal_sessions DB table needed for Phase 1, stateless tokens sufficient
- [Phase 01-foundation]: Patch at usage site in tests: mock 'gateway.channels.slack.resolve_tenant' not 'router.tenant.resolve_tenant' — Python name binding at import time
- [Phase 01-foundation]: Celery payload extension: msg.model_dump() | extras dict, pop extras before model_validate in tasks.py to avoid pydantic validation errors on unknown fields
- [Phase 01-foundation]: Bot token for chat.update loaded from channel_connections.config['bot_token'] in orchestrator task — keeps Slack SDK out of orchestrator package
- [Phase 02-agent-features]: HMAC uses hmac.new() with hmac.compare_digest for timing-safe WhatsApp signature verification
- [Phase 02-agent-features]: meta-media://{media_id} placeholder URL at normalization time; actual download in adapter after tenant resolution
- [Phase 02-agent-features]: WhatsApp thread_id = sender wa_id (WhatsApp has no threading; conversation scope is per phone number)
- [Phase 02-agent-features]: Always return HTTP 200 to Meta webhooks regardless of processing errors to prevent retry storms
- [Phase 02-agent-features]: pgvector/pgvector:pg16 Docker image required for pgvector extension — postgres:16-alpine does not include vector extension control file
- [Phase 02-agent-features]: SentenceTransformer loaded as lazy singleton — model loaded once on first use to avoid per-call 2s overhead; 384d all-MiniLM-L6-v2 matches vector(384) column
- [Phase 02-agent-features]: embed_and_store Celery task is fire-and-forget (ignore_result=True) — embedding backfill never blocks LLM response path
- [Phase 02-agent-features]: Keyword-based conversation metadata detection (v1) uses billing keywords + attempt counter from sliding window — simple and sufficient for initial escalation rules
- [Phase 02-agent-features]: Escalation condition parser uses regex not eval — safe, no code injection risk, supports 'keyword AND count > N' format
- [Phase 02-agent-features]: No-op audit logger stub in tasks.py allows escalation to function before Plan 02 audit module ships — one-import swap when ready
- [Phase 02-agent-features]: CAST(:metadata AS jsonb) for asyncpg JSONB params — :: cast syntax fails with named params
- [Phase 02-agent-features]: Migration 004 (not 003) for audit_events — 003_escalation_fields.py claimed revision 003 first
- [Phase 02-agent-features]: AuditLogger uses raw INSERT text() — ORM model would allow accidental SQLAlchemy UPDATE/DELETE on audit rows
- [Phase 02-agent-features]: boto3 added to gateway pyproject.toml explicitly — was used via local import in whatsapp.py but never declared, causing ModuleNotFoundError in tests
- [Phase 02-agent-features]: boto3 patched at import site patch('boto3.client') not patch('module.boto3') — local imports inside async functions require patching the actual module, not the module attribute
- [Phase 02-agent-features]: build_messages_with_media() wraps build_messages_with_memory() — media enrichment is additive, all memory context preserved alongside image_url blocks
- [Phase 02-agent-features]: AUDIO/VIDEO attachments text-referenced only in v1 — OpenAI image_url blocks support images only, not audio/video
- [Phase 02-agent-features]: Module-level imports in tasks.py for testability — patchable at orchestrator.tasks.*
- [Phase 02-agent-features]: Unified extras dict carries channel-specific metadata (Slack + WhatsApp) through entire pipeline
- [Phase 02-agent-features]: wa_id extracted from sender.user_id in handle_message after model_validate and injected into extras
- [Phase 03-operator-experience]: AuditEvent ORM attribute renamed from 'metadata' to 'event_metadata' — SQLAlchemy 2.0 DeclarativeBase reserves 'metadata'; mapped_column('metadata') preserves DB column name
- [Phase 03-operator-experience]: StripeClient(api_key=settings.stripe_secret_key) — new v14+ thread-safe API, not legacy stripe.api_key module-level approach
- [Phase 03-operator-experience]: Stripe webhook idempotency via StripeEvent INSERT + flush + IntegrityError catch — handles Stripe at-least-once delivery
- [Phase 03-operator-experience]: LLM key listing returns key_hint (last 4 chars only) — portal displays ...ABCD without decrypting Fernet ciphertext
- [Phase 03-operator-experience]: window.location.href for Stripe redirects (not router.push) — Stripe Checkout/Portal URLs are external domains
- [Phase 03-operator-experience]: use(searchParams) in client components for Next.js 15 — searchParams is a Promise, must be unwrapped with React.use()
- [Phase 03-operator-experience]: BillingStatus uses inline Tailwind color classes — existing Badge variants lack semantic blue/green/amber/red states needed for subscription status
- [Phase 03-operator-experience]: recharts installed with --force due to npm ENOTEMPTY race bug — was in package.json but not node_modules
- [Phase 03-operator-experience]: Usage nav links to /usage tenant picker (not hardcoded tenantId) — supports multi-tenant operators
- [Phase 03-operator-experience]: BudgetAlertBadge renders neutral 'No limit set' for null budget_limit_usd — prevents false alarms
- [Phase 03-operator-experience]: Agent goes live automatically (is_active true by default) — no separate Go Live button in onboarding wizard (per user decision)
- [Phase 03-operator-experience]: Test message step is REQUIRED in onboarding — no skip button (per user decision)
- [Phase 03-operator-experience]: Onboarding wizard step state in URL searchParams (step=1|2|3) — shareable and browser-refresh safe
- [Phase 03-operator-experience]: Portal git initialized as submodule with own .git repo — enables atomic per-task commits in packages/portal; parent repo tracks gitlink
- [Phase 03-operator-experience]: Agent goes live automatically after test message — is_active is true by default, no separate Go Live button (per user decision)
- [Phase 03-operator-experience]: Test message step is REQUIRED in onboarding — no skip button (per user decision)
- [Phase 03-operator-experience]: Onboarding wizard step state in URL searchParams (step=1|2|3) — shareable and browser-refresh safe
- [Phase 03-operator-experience]: Portal git initialized as submodule with own .git repo — enables atomic per-task commits in packages/portal; parent repo tracks gitlink
- [Phase 03-operator-experience]: window.location.href used for Stripe redirects (not router.push) — Stripe Checkout/Portal URLs are external domains
- [Phase 03-operator-experience]: use(searchParams) in billing page client component — Next.js 15 searchParams is a Promise, must be unwrapped with React.use()
- [Phase 03-operator-experience]: BillingStatus uses inline Tailwind color classes — existing Badge variants lack semantic blue/green/amber/red states needed for subscription status
- [Phase 03-operator-experience]: recharts installed with --force due to npm ENOTEMPTY race bug — was in package.json but not node_modules
- [Phase 03-operator-experience]: Usage nav links to /usage tenant picker (not hardcoded tenantId) — supports multi-tenant operators
- [Phase 03-operator-experience]: BudgetAlertBadge renders neutral 'No limit set' for null budget_limit_usd — prevents false alarms
- [Phase 03-operator-experience]: All Phase 3 portal routers (portal, billing, channels, llm_keys, usage, webhook) mounted directly on gateway FastAPI app
- [Phase 04-rbac]: Role stored as TEXT+CHECK (not sa.Enum) per Phase 1 ADR to avoid Alembic DDL conflicts
- [Phase 04-rbac]: SHA-256 hash of raw invite token stored in DB — token_to_hash enables O(1) lookup without exposing token
- [Phase 04-rbac]: platform_admin bypasses tenant membership check entirely (no DB query) for simpler, faster guard logic
- [Phase 04-rbac]: Celery invite email task dispatched via lazy local import in invitations.py to avoid shared->orchestrator circular dep
- [Phase 04-rbac]: base-ui DialogTrigger uses render prop not asChild — fixes TypeScript error in portal components
- [Phase 04-rbac]: base-ui Select onValueChange typed as (string | null) — filter state setters use ?? '' to coerce null
- [Phase 04-rbac]: Operator test-message endpoint uses require_tenant_member not require_tenant_admin — locked decision: operators can QA agent behavior without CRUD access
- [Phase 04-rbac]: Impersonation logs via raw SQL INSERT into audit_events — consistent with audit table immutability design (UPDATE/DELETE revoked at DB level)
- [Phase 05-employee-design]: AgentTemplate is global (not tenant-scoped) — templates readable by all authenticated users, no RLS; deploy creates independent Agent snapshot
- [Phase 05-employee-design]: build_system_prompt() always appends AI transparency clause — non-negotiable per Phase 1 architectural decision
- [Phase 05-employee-design]: Template GET endpoints use get_portal_caller (not require_tenant_member) — no tenant_id path param in global template routes
- [Phase 05-employee-design]: Wizard state held in React useState — persona text in URL would be impractical; step position exposed via URL searchParam only
- [Phase 05-employee-design]: Channels step is informational in v1 — agent routing is tenant-scoped, not per-agent; no channel-agent join table in v1
- [Phase 05-employee-design]: All three creation paths (template, wizard, advanced) confirmed working by human review before Phase 5 marked complete
- [Phase 05-employee-design]: /agents/new added to CUSTOMER_OPERATOR_RESTRICTED — startsWith check covers all sub-paths automatically
- [Phase 05-employee-design]: catch re-throw in handleDeploy is minimal fix — existing createAgent.error UI was correctly wired, just never received the error
- [Phase 06-web-chat]: WebSocket auth via first JSON message after connection — browser WebSocket API cannot send custom HTTP headers
- [Phase 06-web-chat]: thread_id = conversation_id in web channel normalizer — scopes agent memory to one web conversation per conversation ID
- [Phase 06-web-chat]: Redis pub-sub delivery: orchestrator publishes to webchat_response_key, WebSocket subscribes with 60s timeout before sending to client
- [Phase 06-web-chat]: useSearchParams wrapped in Suspense boundary — Next.js 16 static prerendering requires Suspense for pages using URL params
- [Phase 06-web-chat]: Stable callback refs in useChatSocket — onMessage/onTyping held in refs so WebSocket effect re-runs only when conversationId or auth changes
- [Phase 06-web-chat]: All CHAT requirements (CHAT-01 through CHAT-05) verified by human testing before Phase 6 marked complete
- [Phase 07-multilanguage]: LANGUAGE_INSTRUCTION appended before AI_TRANSPARENCY_CLAUSE — transparency clause remains last (non-negotiable per Phase 1)
- [Phase 07-multilanguage]: Translation overlay at response time (not stored) — English values never overwritten in DB
- [Phase 07-multilanguage]: auth/verify response includes language field — Auth.js JWT can carry it without additional per-request DB queries
- [Phase 07-multilanguage]: PortalUser.language server_default='en' — existing users get English without data migration
- [Phase 07-multilanguage]: i18n/locales.ts created to separate client-safe constants from server-only i18n/request.ts (next/headers import)
- [Phase 07-multilanguage]: Cookie name konstruct_locale for cookie-based locale with no URL routing
- [Phase 07-multilanguage]: LanguageSwitcher isPreAuth prop skips DB PATCH and session.update() on login page
- [Phase 07-multilanguage]: onboarding/page.tsx uses getTranslations() not useTranslations() — Server Component requires next-intl/server import
- [Phase 07-multilanguage]: billing-status.tsx trialEnds key uses only {date} param — boolean ICU params rejected by TypeScript strict mode
- [Phase 08-mobile-pwa]: mobileShowChat state toggles chat view on mobile — CSS handles desktop, state handles mobile nav pattern (WhatsApp-style)
- [Phase 08-mobile-pwa]: 100dvh for mobile chat container height — handles iOS Safari bottom chrome shrinking the layout viewport
- [Phase 08-mobile-pwa]: Serwist v9 uses new Serwist() class + addEventListeners() — installSerwist() was removed in v9 API
- [Phase 08-mobile-pwa]: Serwist service worker disabled in development (NODE_ENV !== production) — avoids stale cache headaches during dev
- [Phase 08-mobile-pwa]: Mobile More sheet uses plain div + backdrop (not @base-ui/react Drawer) — simpler implementation, zero additional complexity
- [Phase 08-mobile-pwa]: Viewport exported separately from metadata in app/layout.tsx — Next.js 16 requirement
- [Phase 08-mobile-pwa]: Serwist class API (new Serwist + addEventListeners) used over deprecated installSerwist — linter enforced this in serwist 9.x
- [Phase 08-mobile-pwa]: Migration numbered 012 (not 010 as planned) — migrations 010 and 011 used by template data migrations added after plan was written
- [Phase 08-mobile-pwa]: Push router in shared/api/push.py (not gateway/routers/push.py) — consistent with all other API routers in shared package
- [Phase 08-mobile-pwa]: urlBase64ToArrayBuffer returns ArrayBuffer not Uint8Array<ArrayBufferLike> — TypeScript strict mode requires ArrayBuffer for PushManager.subscribe applicationServerKey
- [Phase 08-mobile-pwa]: Connected user tracking via module-level _connected_users dict in web.py — avoids Redis overhead for in-process WebSocket state
- [Phase 08-mobile-pwa]: All six MOB requirements approved by human testing on mobile viewports — no rework required
- [Phase 09-testing-qa]: fullyParallel: false for Playwright CI stability — shared DB state causes race conditions with parallel test workers
- [Phase 09-testing-qa]: serviceWorkers: block in playwright.config.ts — Serwist intercepts test requests without this flag
- [Phase 09-testing-qa]: routeWebSocket regex /\/chat\/ws\// not string URL — portal derives WS base from NEXT_PUBLIC_API_URL which is absolute and environment-dependent
- [Phase 09-testing-qa]: lighthouserc.json uses error (not warn) at minScore 0.80 for all 4 categories — plan hard floor requirement
- [Phase 09-testing-qa]: a11y.spec.ts uses axe fixture (not makeAxeBuilder) — axe.spec.ts removed due to TypeScript errors
- [Phase 09-testing-qa]: Serious a11y violations are console.warn only — critical violations are hard CI failures
- [Phase 09-testing-qa]: No mypy --strict in CI — ruff lint is sufficient gate; mypy can be added incrementally when codebase is fully typed
- [Phase 09-testing-qa]: seed_admin uses || true in CI — test users created via E2E auth setup login form, not DB seeding

### Roadmap Evolution

- Phase 4 added: RBAC — 3-tier role-based access control (platform admin, customer admin, customer operator) with invitation flow

### Pending Todos

None yet.

### Blockers/Concerns

None — all phases complete.

## Session Continuity

Last session: 2026-03-26T05:17:22.325Z
Stopped at: Phase 10 context gathered
Resume file: .planning/phases/10-agent-capabilities/10-CONTEXT.md
