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.
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:
vibecode/sdk/agent.ts wrapper with audit + cost attribution.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.
https://chat.<TBD> (or https://<obs-app-domain>/chat if we reuse the obs app domain — see decisions §8).mmbtang@gmail.comlt;your-gmailmmbtang@gmail.comgt; credentials + TOTP. Same dual-auth Step 4.5 pattern.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.
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.
| # | 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.
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.tsexport 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.tsexport 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.
New DDB table: ai-sandbox-chatbot-threads
| Attribute | Type | Notes |
|---|---|---|
user_id (PK) | String | Cognito sub of the thread owner |
thread_id (SK) | String | UUID; new thread gets a new one |
title | String | Auto-generated from first user message (Bedrock summarization helper, future) |
messages | List<Map> | Ordered list of { role, content, timestamp, tokenCount } |
created_at | Number | Epoch seconds |
last_updated_at | Number | Epoch seconds; used for thread-list sort order |
message_count | Number | For UI badges; computed on update |
total_cost_usd | Number | Running 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.
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.
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).
RESPONSE_STREAM emits chunks as they're produced (no buffering).InvokeModelWithResponseStream emits its own chunks; Lambda passes them through.fetch() with response.body.getReader() — standard ReadableStream API.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.
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.
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.
Options: (a) reuse obs app domain at /chat. (b) new CloudFront distribution with its own domain (e.g. chat.<cloudfront-id>.cloudfront.net).
/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.Options: (a) eng-low-risk (same cell as obs app backend). (b) new chatbot-low-risk cell.
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.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).
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."
agent.ts exposes guardrails + system prompts as standard inputs, not custom hacks. Tested behavior for vibe apps 2+3 to inherit.Options: (a) infinite (manual delete only). (b) 90-day auto-delete via DDB TTL. (c) configurable per user.
| Mistake / attack | Caught by | How the operator sees it |
|---|---|---|
Developer adds a route that calls BedrockRuntimeClient directly | Tier role permission boundary | Runtime AccessDeniedException; SDK wrapper is the only Bedrock-permitted path |
Developer adds a route that forgets audit.emit() | SDK middleware withAuditEnforcement | Response replaced with structured 500 + violation logged |
Developer calls invokeAgent() without attribution | SDK input validation | SDK throws before Bedrock call; nothing reaches AWS |
Developer tries to deploy with governance_cell: "made-up-name" | Terraform plan | Plan fails: "cell made-up-name not in data_governance_kms_keys" |
Developer tries to add a DDB table tagged boundary: pii for the chatbot | App Review human review | PR 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 chat | PII-shape detector in audit emitter | Email/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 guardrail | Substrate Bedrock guardrail | Bedrock returns a moderation response; SDK passes it through; UI shows the moderation message |
| Lambda errors mid-stream | Stream cleanup in handler | UI shows partial response + error message; audit event fires with status: 'stream_failed'; partial cost attributed |
| Direct hit to the Lambda Function URL ARN | Function URL AWS_IAM + OAC SigV4 requirement | 403 Forbidden; only CloudFront's signed requests reach Lambda |
Files and resources that exist at end of Phase A:
ai-sandbox-chatbot-threads (per the data layer §6)eng-low-risk per Decision 8.3vibecode/sdk/agent.ts — Bedrock streaming wrapper with attributionvibecode/sdk/audit.ts — structured event emission with PII detectionvibecode/sdk/cost.ts — cost attribution + CloudWatch metric emissionvibecode/sdk/governance/cell.ts — Terraform helper to wire cell CMK ARNvibecode/sdk/app-template/ — Terraform module taking (app_name, governance_cell)cartridges/global/chatbot/ — Terraform module consuming the app-templatecartridges/global/chatbot/lambda/ — Node 20 ARM64 Lambda handler using the SDKcartridges/global/chatbot/frontend/ — SPA: index.html, app.js, styles.csssubstrate/environments/ai-sandbox/main.tfagent.ts, audit.ts, cost.ts from "planned" to "Phase A delivery"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.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.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.
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.
lockbox_only_pii is vacuously satisfied for the chatbot (no PII in the chat). But the PII-shape detector ships anyway, so vibe app 2 inherits tested plumbing instead of building it itself. The SDK matures by serving the easy case first; the hard cases inherit.