PAYCARGO · AI-SANDBOX · PROCESS

6.aPhase A — Chatbot, the Console's first dogfood

A login + Bedrock chatbot with streaming responses, every interaction audited and cost-attributed, every thread persisted in DDB. The minimum app that exercises all 5 Console contracts (4 actively, 1 vacuously). The SDK packages crystallize as code because a real consumer drives the API design, not the other way around.
Block: 6.a (Phase A delivery of Step 6's Console doctrine) · Audience: Just the platform team this week (substrate-first per the lock-in) · Cost: <$5/month at expected dev usage (Haiku is cheap; CloudFront + DDB on-demand are pennies) · Permanence: Permanent — this is how every future vibe app gets built
Specced

State: Phase A redefined from "docs only" to "build the dogfood that proves the SDK." Doctrine survives because the dogfood IS the documentation, structurally executed. Doc-first instruction still honored: this spec lives before any code.

Why this exists: Step 6 lays out the Console as five surfaces + three constraints + five contracts. The contracts are abstract until something real exercises them. A login + streaming chatbot + persistent thread is the smallest end-to-end interaction that touches Bedrock, hits all four runtime contracts, and persists state through the DB layer. Without this dogfood, the SDK is specced in a vacuum. With it, the SDK shape is validated under real usage before Cali's developer hits it.

Executive Summary

A working chatbot app shipped this week, on the substrate, consuming a freshly-coded Console SDK. the user signs in, opens a thread, types a message, sees Claude Haiku stream a response, and every byte of that interaction is audited, cost-attributed, and persisted to DDB. The SDK packages get written because the chatbot demands them, not because a doc proposed them.

Five SDK contracts to validate: governance_cell three_layer_defense lockbox_only_pii audit_emit_required cost_attribute. This chatbot exercises four of them actively; lockbox_only_pii is satisfied vacuously (no PII flows through the chat surface) but its plumbing — the PII-shape detector in the audit emitter — ships anyway, so vibe app 2 (Sales reports) inherits a tested boundary instead of building it themselves. The result: a real app for the platform owner to chat with Claude through the platform AND the first three SDK packages (audit.ts, cost.ts, agent.ts) born from a real consumer. Phase A delivery: end of this week, before Cali.

1. Why a chatbot for Phase A (not docs-only)

The original Step 6 framing put Phase A as "docs only — spec the SDK, no code." That's the safe path: lock the doctrine, then build. The risk: SDK packages specced in a vacuum tend to be wrong in subtle ways that only show up at integration time, after the developer's mental model has already congealed around the wrong shape.

The chatbot reframe: ship the smallest real app that exercises four of the five contracts. The SDK packages get written as the chatbot demands them. The shape is validated under real usage before vibe app 2 lands next week with the the external developer. The doctrine is preserved (still doc-first — this Step 6.a doc precedes the code) but the validation is structural, not theoretical.

Why a chatbot specifically:

The "doc-first" principle survives

This Step 6.a doc is written before any code. The doctrine for the chatbot — its contracts, its data model, its SDK surface — is fixed before Lambda or Terraform is touched. The chatbot's purpose is to execute the doctrine, not to invent it.

2. The user flow — what the user actually sees

  1. the user opens https://chat.<TBD> (or https://<obs-app-domain>/chat if we reuse the obs app domain — see decisions §8).
  2. Cognito Hosted UI — sign in with the existing mmbtang@gmail.comlt;your-gmailmmbtang@gmail.comgt; credentials + TOTP. Same dual-auth Step 4.5 pattern.
  3. App loads. Left rail: thread list (the user's previous threads). Main pane: empty (or last open thread).
  4. the user clicks "New thread" — a thread row appears in the rail; main pane shows an empty conversation.
  5. the user types a message in the input box at the bottom and hits send.
  6. Within ~300ms, Claude Haiku starts streaming tokens into a new assistant message bubble. the user sees the response materialize character-by-character.
  7. When streaming completes, the thread updates in DDB. Audit event fires (one for the user message, one for the assistant response). Cost attribution lands on the chatbot app's bucket. Thread title auto-generated from first user message.
  8. the user can navigate back to any past thread — threads persist across sessions, keyed by user ID. Each thread has its own conversation context that gets fed to Bedrock on each new message.
  9. Each interaction is measured. Every Bedrock call emits structured audit (user, thread, model, input/output tokens, cost). Console's cross-app observability surface aggregates these.

No PII flows through this app. The conversation is the platform owner asking Claude things about the platform, the substrate, the strategy. lockbox_only_pii contract is satisfied vacuously, but its plumbing — the PII detector in the audit emitter — runs anyway.

3. Architecture — substrate, Console, app layers in this delivery

App layer (chatbot) ┌─────────────────────────────────────────────────────────────────────┐ │ Frontend (SPA): │ │ - /index.html, /app.js, /styles.css served via CloudFront │ │ - thread list + message pane + input + streaming response area │ │ - localStorage for PKCE verifier; sessionStorage for tokens │ │ - calls SDK helpers exclusively; no direct fetch() to Bedrock │ │ │ │ Backend (Lambda Node 20 ARM64): │ │ - handler.js: routes for /api/threads, /api/threads/{id}, │ │ /api/threads/{id}/messages (POST streams the response) │ │ - uses vibecode/sdk/agent.ts for Bedrock invocation │ │ - uses vibecode/sdk/audit.ts for every operation │ │ - uses vibecode/sdk/cost.ts (implicitly via agent.ts) │ │ - JWT validation via existing X-Auth-Token + aws-jwt-verify │ └────────────────────────┬────────────────────────────────────────────┘ │ │ exclusively via SDK │ Console layer (SDK + contracts) ┌────────────────────────▼────────────────────────────────────────────┐ │ vibecode/sdk/agent.ts │ │ invokeAgent({ model, messages, attribution }) → AsyncIterable │ │ - calls Bedrock InvokeModelWithResponseStream │ │ - emits audit event before + after │ │ - attaches cost attribution tags │ │ - refuses call if attribution is missing │ │ │ │ vibecode/sdk/audit.ts │ │ emit({ app, op, user, payload }) → void │ │ - PutLogEvents to per-app CloudWatch log group │ │ - PII-shape detector scrubs values that look like names, emails │ │ - structured JSON; one event = one operation │ │ │ │ vibecode/sdk/cost.ts │ │ attribute(opts) → CostAttribution │ │ - per-invocation cost tracking │ │ - reads token counts from Bedrock response metadata │ │ - tags app, cell, purpose for cross-app cost dashboards │ │ │ │ vibecode/sdk/governance/cell.ts │ │ consumesCell("eng-low-risk") declaration in app's Terraform │ │ - plan-time check the cell exists │ │ - wires the cell's CMK ARN onto all KMS-encrypted resources │ │ │ │ vibecode/sdk/app-template/ │ │ Terraform module that takes (app_name, governance_cell) │ │ - emits CloudFront + S3 + Lambda + Cognito + DDB + log group │ │ - inherits three-layer defense pattern from Step 4.5.b │ └────────────────────────┬────────────────────────────────────────────┘ │ │ substrate primitives only │ Substrate layer (Steps 0–4) ┌────────────────────────▼────────────────────────────────────────────┐ │ Bedrock (Claude Haiku 4.5) │ │ Data governance CMK: eng-low-risk (or new "chatbot-low-risk") │ │ DDB chatbot-threads table (new) — partition by user_id │ │ Cognito user pool (reuse obs-app pool OR new pool — decision §8) │ │ CloudFront + OAC for Lambda (Step 4.5.b pattern) │ │ CloudWatch log group on backend governance CMK │ └─────────────────────────────────────────────────────────────────────┘

Every layer boundary is the substrate's: app talks only to Console; Console talks only to substrate. App's Terraform module imports the Console's app-template. App's Lambda code imports the Console's SDK packages. No direct AWS SDK calls from app code — that's the lockout chip running for real for the first time.

4. SDK contracts exercised

# Contract How the chatbot exercises it Active or vacuous
1 governance_cell App's Terraform declares consumesCell("eng-low-risk"). Frontend bucket + Lambda log group + DDB chatbot-threads table all encrypted with the cell's CMK. SDK refuses to deploy if the cell name isn't in the live grid. Active
2 three_layer_defense Reused via the app-template: CloudFront with OAC for both S3 frontend and Lambda backend; Function URL AuthType = AWS_IAM; JWT in X-Auth-Token. Identical pattern to the obs app; second confirmation the pattern composes. Active
3 lockbox_only_pii The audit emitter's PII-shape detector runs on every audit event. Chat content gets scrubbed values like emails or phone numbers if they appear. The SDK's "writes to DDB tables tagged boundary: pii" check fires — chatbot-threads table is tagged boundary: non-pii, so writes go through cleanly. If a future change to the chatbot tried to write user PII, the SDK would refuse. Vacuous (no PII in chat) + tested plumbing
4 audit_emit_required Every route handler is required to call audit.emit() before returning. SDK middleware checks; if the handler returns without emitting, the response is replaced with a 500 + log warning. For the chatbot: send-message emits (user_input event), receive-stream-complete emits (assistant_response event), thread-create/delete emit, sign-in emits. Active
5 cost_attribute Every Bedrock invokeAgent() call carries { app: "chatbot", cell: "eng-low-risk", purpose: "user_chat", thread_id: ... }. SDK refuses the Bedrock call if attribution is missing. Token counts read from response metadata; cost computed and emitted to CloudWatch metric with attribution dimensions. Active

Four contracts actively driven; one vacuously satisfied but with its enforcement plumbing tested. Vibe app 2 (Sales reports with real PII) inherits the tested plumbing for contract 3 instead of building it from scratch.

5. SDK packages that crystallize

Three SDK packages get written as TypeScript modules, packaged for Lambda Node 20 runtime. No CodeArtifact registry yet (that's Phase D); for Phase A they're imported via relative path from the app's lambda/ directory. Mono-repo shape, single source of truth.

vibecode/sdk/agent.ts

// API surface (signatures only — actual code in Phase A delivery)
export interface AgentInvocation {
  model: 'claude-haiku-4-5' | 'claude-sonnet-4-6';
  messages: ChatMessage[];
  attribution: {
    app: string;       // required — no default
    cell: string;      // required — must be in governance grid
    purpose: string;   // required — operational tag
    threadId?: string; // optional but recommended
  };
  opts?: { temperature?: number; maxTokens?: number };
}

export interface ChatMessage {
  role: 'user' | 'assistant' | 'system';
  content: string;
}

// Streams response chunks back as they arrive from Bedrock
export async function* invokeAgent(
  inv: AgentInvocation
): AsyncIterable<{ text: string; done: boolean; usage?: TokenUsage }>;

// Convenience for non-streaming callers
export async function invokeAgentSync(inv: AgentInvocation): Promise<string>;

Internally: validates attribution (throws if missing), emits start audit event, calls Bedrock's InvokeModelWithResponseStream, accumulates token counts, emits completion audit event with cost attribution and usage.

vibecode/sdk/audit.ts

export interface AuditEvent {
  app: string;
  op: string;          // 'chat.message.send', 'chat.thread.create', etc.
  user: string;        // Cognito sub or username
  timestamp?: string;  // auto-filled if missing
  payload: Record<string, unknown>;
  // PII-shape detector scrubs values matching email/phone/SSN patterns
  // and replaces them with `[REDACTED:<shape>]` before emission.
}

export async function emit(event: AuditEvent): Promise<void>;

// Middleware for Lambda handlers — ensures emit() was called before
// the route handler's response is sent. If not, the response is replaced
// with a structured 500 and the operation is logged as a violation.
export function withAuditEnforcement(
  handler: RouteHandler
): RouteHandler;

Internally: puts a structured JSON event into a per-app CloudWatch log group on the backend governance CMK. PII detection uses a denylist of regex patterns (email, US phone, SSN-shape, common name forms). Scrubbed values become [REDACTED:email] etc.

vibecode/sdk/cost.ts

export interface CostAttribution {
  app: string;
  cell: string;
  purpose: string;
  // operational metadata
  inputTokens: number;
  outputTokens: number;
  model: string;
}

export function computeCost(usage: TokenUsage, model: string): number;

export async function emitCost(attr: CostAttribution): Promise<void>;

Internally: puts a custom CloudWatch metric (AISandbox/Vibe/BedrockCost) with dimensions app, cell, purpose, model. The cross-app observability surface in Phase C reads from this namespace.

6. Data layer — thread persistence + audit emission

Threads table

New DDB table: ai-sandbox-chatbot-threads

AttributeTypeNotes
user_id (PK)StringCognito sub of the thread owner
thread_id (SK)StringUUID; new thread gets a new one
titleStringAuto-generated from first user message (Bedrock summarization helper, future)
messagesList<Map>Ordered list of { role, content, timestamp, tokenCount }
created_atNumberEpoch seconds
last_updated_atNumberEpoch seconds; used for thread-list sort order
message_countNumberFor UI badges; computed on update
total_cost_usdNumberRunning sum; cost transparency at the thread level

Encryption: at-rest with the chatbot app's governance cell CMK. The substrate's gateway endpoint policy (Layer 1 of the four-layer DB boundary) constrains which DDB actions are even reachable; the app's Lambda execution role grants only Get/Put/Update/Query on this specific table.

Tags: boundary: non-pii, governance_cell: eng-low-risk, owner: chatbot-app. The SDK's PII boundary check reads the table's tags before issuing a write.

Audit log group

Per-app CloudWatch log group: /aws/vibe-apps/chatbot/audit. Encrypted with eng-low-risk CMK. Retention 365 days for Phase A (longer retention is a Phase D decision). One log event per audit.emit() call. Structured JSON; consumable by CloudWatch Logs Insights and by the cross-app observability surface in Phase C.

7. Streaming — Bedrock response → Lambda → browser

Phase A uses Lambda Function URL Response Streaming (not API Gateway WebSocket, not Server-Sent Events directly from the SPA — we use Lambda's native streaming output mode).

Why Function URL streaming

The full streaming chain

Browser fetch('/api/threads/{id}/messages', { method: 'POST', body: { content: '...' } }) res.body.getReader() → consumes chunks as they arrive │ ▼ CloudFront /api/* matches ordered_cache_behavior → Lambda Function URL origin (OAC-signed) Cache policy: CachingDisabled (no buffering) Origin request policy: AllViewerExceptHostHeader │ ▼ Lambda Function URL (RESPONSE_STREAM mode) AWS_IAM auth (CloudFront OAC signature passes) │ ▼ Lambda handler (handler.js) invocationMode: 'RESPONSE_STREAM' awslambda.streamifyResponse((event, responseStream, ctx) => { ... }) │ ▼ SDK invokeAgent() → Bedrock InvokeModelWithResponseStream emits audit start event │ ▼ async iterable of chunks │ Lambda writes each chunk to responseStream.write() Each chunk: { type: 'content_block_delta', delta: { text: '...' } } Adapted to a simpler envelope before write: { text: '...' } │ ▼ On stream complete: - persist final assistant message to DDB thread - emit audit completion event (with usage + cost) - responseStream.end()

Persistence ordering

The thread is updated after the assistant response streams in full. If the Lambda errors mid-stream, the user's message is retained in DDB but the partial assistant response is dropped. The audit event for the partial completion fires with an explicit { status: 'stream_failed', reason: ... } shape; cost is attributed for whatever tokens did flow.

Future enhancement (Phase B+): chunk-buffered persistence for very long responses so a mid-stream failure preserves partial work. Out of scope for Phase A.

8. Open decisions, with recommendations

Several shaping decisions where the right answer is non-obvious. Each presented with my recommended default and reasoning. Push back where the recommendation is wrong.

Decision 8.1 — Cognito user pool: reuse obs-app pool or new?

Options: (a) reuse the obs-app pool (us-east-1_fJLnhjqnl), giving the user one set of credentials across both apps. (b) new per-app pool, giving stricter app-by-app isolation but two sign-ins.

Recommendation: reuse the obs-app pool. Same dual-auth invariant; same TOTP enrollment per user; single source of group membership truth. Single pool with multiple app clients (one per vibe app) is the standard pattern and exercises that pattern under real load. Per-app pool would re-create Cognito + invite + verification overhead for every new vibe app, which kills the Console's "shipping a new app is fast" property.

Decision 8.2 — Domain: subpath of obs app or new subdomain?

Options: (a) reuse obs app domain at /chat. (b) new CloudFront distribution with its own domain (e.g. chat.<cloudfront-id>.cloudfront.net).

Recommendation: new CloudFront distribution. Each vibe app gets its own distribution. Cleaner separation, dogfoods the app-template Terraform module, and tests the Console's "ship a new app" path end-to-end. Cost is the same. The obs app's /api/* ordered behavior would interfere with adding chat routes to the same distribution, and a single SPA shell trying to host both apps would muddle the SDK boundary.

Decision 8.3 — Governance cell for the chatbot

Options: (a) eng-low-risk (same cell as obs app backend). (b) new chatbot-low-risk cell.

Recommendation: eng-low-risk for Phase A. the operator (Engineering audience) is the only user. No new substrate CMK needed. If Phase C broadens the chatbot to a CEO-office audience, that triggers a Step 4.5.a substepping that adds a ceo-office-low-risk cell consumption alongside.

Decision 8.4 — Bedrock model

Options: (a) Claude Haiku 4.5 (cheapest, fastest, <$0.001 per typical interaction). (b) Claude Sonnet 4.6 (more capable but more expensive per token). (c) Configurable per-thread (the operator picks).

Recommendation: Haiku 4.5 for Phase A. The chatbot is the platform owner chatting with Claude about the platform; Haiku handles this well at <1¢ per long conversation. Sonnet is for vibe apps where reasoning matters more (the obs app's daily narrative uses Haiku for the same reason). Configurable model per-thread is a Phase B feature; Phase A locks Haiku to keep the SDK contract simple.

Decision 8.5 — System prompt / guardrails

Options: (a) bare model (no system prompt, no guardrail). (b) substrate's default Bedrock guardrail attached (PROMPT_ATTACK off-strength, content moderation on). (c) system prompt that tells Claude it's "the platform's chat surface."

Recommendation: (b) + (c) combined. Substrate default guardrail (already deployed in Step 4) catches obvious prompt-injection and content issues. A minimal system prompt sets context ("You are PayCargo's internal platform assistant. Be honest about what you can and can't do. Cite the substrate docs when relevant."). This validates that the SDK's agent.ts exposes guardrails + system prompts as standard inputs, not custom hacks. Tested behavior for vibe apps 2+3 to inherit.

Decision 8.6 — Thread retention

Options: (a) infinite (manual delete only). (b) 90-day auto-delete via DDB TTL. (c) configurable per user.

Recommendation: infinite for Phase A, with manual delete from the UI. Threads at the user's expected volume are tiny in DDB cost. Auto-deletion is a Phase C feature once we have multiple users with widely varying usage patterns. Manual delete is a one-line API.

9. Failure modes — what each contract catches

Mistake / attackCaught byHow the operator sees it
Developer adds a route that calls BedrockRuntimeClient directlyTier role permission boundaryRuntime AccessDeniedException; SDK wrapper is the only Bedrock-permitted path
Developer adds a route that forgets audit.emit()SDK middleware withAuditEnforcementResponse replaced with structured 500 + violation logged
Developer calls invokeAgent() without attributionSDK input validationSDK throws before Bedrock call; nothing reaches AWS
Developer tries to deploy with governance_cell: "made-up-name"Terraform planPlan fails: "cell made-up-name not in data_governance_kms_keys"
Developer tries to add a DDB table tagged boundary: pii for the chatbotApp Review human reviewPR blocks: chatbot doesn't have lockbox integration; reviewer asks "are you sure this isn't a vibe-app-2 concern?"
User pastes their email or phone into chatPII-shape detector in audit emitterEmail/phone scrubbed in the audit log; chat itself still shows the input to the user (not blocking user-facing UX, just scrubbing audit)
Bedrock returns content that triggers the guardrailSubstrate Bedrock guardrailBedrock returns a moderation response; SDK passes it through; UI shows the moderation message
Lambda errors mid-streamStream cleanup in handlerUI shows partial response + error message; audit event fires with status: 'stream_failed'; partial cost attributed
Direct hit to the Lambda Function URL ARNFunction URL AWS_IAM + OAC SigV4 requirement403 Forbidden; only CloudFront's signed requests reach Lambda

10. Concrete deliverables list

Files and resources that exist at end of Phase A:

Substrate additions

Console SDK packages

Chatbot app

Docs

11. This-week rollout

  1. Day 1 (today): This doc reviewed and committed. the platform owner says yes / fix this / fix that. Open decisions in §8 locked.
  2. Day 2: SDK packages (agent.ts, audit.ts, cost.ts) written as standalone TypeScript modules in vibecode/sdk/. Unit tests covering the attribution validator, the PII detector, and the audit middleware.
  3. Day 3: Terraform app-template module written, taking (app_name, governance_cell). Tested by spinning up a hello-world app that just returns "ok" from a Lambda. Validates the app-template before the chatbot consumes it.
  4. Day 4: Chatbot Lambda handler + DDB integration. Streaming proven via curl before the SPA exists. Audit events visible in CloudWatch.
  5. Day 5: Chatbot SPA. Thread list + message pane + streaming input. End-to-end demo: the user opens the URL, signs in, types "what's the data governance grid?", watches Claude stream the answer, sees the audit + cost events flow.

Five working days, doc-locked decisions, no surprises. The chatbot exists by end of week; the first 3 SDK packages are written and tested; the app-template is proven by a real consumer.

Honest pacing read

The 5-day schedule assumes the platform owner is reviewing this doc and pushing back today, not next week. If the doctrine needs iteration (any of the §8 decisions flips, or contracts §4 need tightening), Day 1 extends and the rest shifts. Cali week (Phase B) lands as planned regardless — the chatbot ships before vibe app 2 starts, not in parallel with it.

12. Doctrine