
Building an AI Incident Commander on Two AWS Databases (and Why One Database Wasn't Enough)
How FleetMind uses Aurora DSQL as a relational system of record and DynamoDB as a high-velocity hot path to build a reliable AI-native incident command platform — and the real architectural tradeoffs that shaped every decision.
This post was created for the purposes of entering the H0: Hack the Zero Stack with Vercel v0 and AWS Databases hackathon. #H0Hackathon
When production breaks at 3am, the bottleneck usually isn't root cause analysis. It's coordination. Someone has to triage severity, page the right people, write three different status updates, and remember to write the post-mortem before the details fade. That's the problem FleetMind sets out to fix: an AI-native incident command platform that acts as an autonomous war room the moment an alert fires.
For the H0 Hackathon, I built FleetMind on Next.js 16, deployed to Vercel, with a 5-agent Claude pipeline sitting on top of two AWS databases doing very different jobs. Here's how the architecture came together, and the tradeoffs that shaped it.
The Core Bet: One Database Is the Wrong Shape for This Problem
Incident tooling has a strange requirement: the tool itself has to be more reliable than whatever it's monitoring. If your incident commander goes down during the incident, you've just made things worse.
At the same time, incident tooling has a write-amplification problem. A single noisy service degradation can fire dozens of alerts in seconds. You need somewhere to absorb that burst cheaply, and somewhere else to hold the structured, relationship-heavy data (incidents, assignments, timelines, post-mortems) that actually needs to be queried, joined, and trusted.
Those are two different jobs. So FleetMind uses two databases, each doing only what it's good at:
- Aurora DSQL as the system of record: incidents, team members, timeline events, runbooks, post-mortems, stakeholder comms. Strongly consistent, relational, multi-region by design.
- DynamoDB as the hot path: raw signal ingestion, dedup queues, session presence. Single-digit-millisecond writes, eventually consistent, and that's fine because nothing here needs a join.
This isn't dual-database complexity for its own sake. It's matching the database to the access pattern.
Aurora DSQL: System of Record, With a Few Real Tradeoffs
Aurora DSQL is PostgreSQL-compatible, which made schema design familiar: eight tables, UUID primary keys,
TIMESTAMPTZ everywhere for global consistency. The schema models the whole incident lifecycle: an incidents table with severity and blast radius, an append-only timeline_events log, incident_assignments linking people to roles, and post_mortems holding the AI-generated retrospective.Two things about DSQL specifically shaped the build:
IAM token auth, not a static password. DSQL connections authenticate via short-lived IAM tokens rather than a connection string with a password baked in. That meant writing a connection helper that signs a fresh token per connection using
@aws-sdk/dsql-signer, rather than just dropping a DATABASE_URL into .env and forgetting about it:1
2
3
4
5
6
7
8
9
import { DsqlSigner } from "@aws-sdk/dsql-signer";
async function generateDsqlToken(): Promise<string> {
const signer = new DsqlSigner({
hostname: process.env.DSQL_ENDPOINT!,
region: process.env.AWS_REGION ?? "eu-north-1",
});
return await signer.getDbConnectAdminAuthToken();
}It's a small extra step, but it's the kind of detail that signals DSQL was built for production auth hygiene from day one, not retrofitted.
No foreign key enforcement at the database level. This is the tradeoff most worth talking about. DSQL's multi-region architecture means it doesn't enforce FK constraints the way a single-region Postgres instance would. Relations are defined in Prisma for ORM-level joins, but referential integrity for anything critical (like confirming an
incident_assignments.userId actually belongs to the right org) has to be checked at the application layer.This isn't a flaw to work around quietly; it's a real architectural consequence of getting multi-region active-active writes, and it's worth designing for explicitly rather than discovering in production. FleetMind handles it with app-level checks scoped by
org_id on every write path, plus Optimistic Concurrency Control (OCC) retry logic for write conflicts:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export async function withOccRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (err: any) {
const isOcc =
err?.code === "40001" ||
err?.message?.toLowerCase().includes("concurrent") ||
err?.message?.toLowerCase().includes("optimistic");
if (isOcc && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
continue;
}
throw err;
}
}
throw new Error("Max OCC retries exceeded");
}The honest takeaway: DSQL trades some single-node conveniences for multi-region durability. For an incident tool where "the database went down during the outage" is the one failure mode that's unacceptable, that trade is the right one.
DynamoDB: Built for the Part That Has to Be Fast and Cheap
The webhook receiver is the front door for every alert: Datadog, CloudWatch, PagerDuty, or a custom integration. It has one job: accept the signal and respond fast, before any AI reasoning happens.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export async function POST(req: NextRequest) {
const body = await req.json();
const signalId = uuidv4();
await writeSignal({
orgId: body.org_id,
serviceId: body.service,
signalId,
timestamp: body.timestamp ?? new Date().toISOString(),
payload: body,
});
return NextResponse.json({ signalId, status: "received" }, { status: 200 });
}That write goes straight into
fleetmind-signals, a DynamoDB table keyed on orgId#serviceId / timestamp#signalId, with a 7-day TTL so the hot path doesn't accumulate forever. DynamoDB's job here is narrow on purpose: absorb bursts of writes instantly, with no schema negotiation and no joins, while the heavier reasoning happens downstream.DynamoDB also backs three more tables: the deduplication queue (
fleetmind-alert-queue), session presence for the live war room (fleetmind-sessions), and, most architecturally interesting of all, a timeline mirror (fleetmind-timeline).Every timeline event is written to DSQL (system of record) and immediately mirrored to DynamoDB. The SSE war room poller reads exclusively from the DynamoDB mirror rather than DSQL. The reason: with multiple engineers watching the same incident, each SSE connection would otherwise hammer DSQL with its own 500ms poll. DynamoDB's single-digit millisecond reads absorb that fan-out cheaply, while DSQL stays authoritative. It's a classic write-through cache pattern, except the "cache" is a separate AWS service with its own TTL (48h) and failure mode. If the mirror write fails, it's non-fatal; the SSE delivery just has a brief delay. DSQL correctness is never compromised.
Where the Two Databases Meet: The AI Pipeline
Once a signal lands in DynamoDB, it triggers a five-agent Claude pipeline, each agent reading from one or both databases depending on what it needs:
1
2
3
4
5
Signal → DynamoDB → Dedup Check → Triage Agent (history-aware)
→ Create Incident (DSQL) → Recurrence Detection
→ Coordinator Agent
→ [Runbook Agent + Comms Agent] (parallel)
→ Resolve → Post-Mortem AgentThe Triage Agent pulls recent signal history from DynamoDB and recent incident history from DSQL in parallel before making a severity call (SEV1–4), so it classifies the signal in context rather than in isolation. After the incident is created, a Recurrence Detection step computes a signature from the incident title, affected services, and severity, then queries DSQL for any prior resolved incident that matches; if found, a warning is appended to the timeline including a list of any unresolved action items from the prior post-mortem. The Coordinator Agent reads the team roster from DSQL (on-call status, timezone, expertise) to assign roles without a human in the loop. The Runbook and Comms agents then run in parallel: the runbook agent matches against existing runbooks using TF-based sparse vector embeddings with cosine similarity computed in application code (DSQL has no pgvector, so vectors are stored as JSON and compared in-process), generating a new runbook if no match exceeds the confidence threshold. The comms agent drafts all three stakeholder tiers in a single Claude call.
Every agent output is Zod-validated, and every agent has a fallback template if the model call fails, because an AI pipeline that throws an unhandled exception during a live incident is its own kind of outage.
What This Architecture Gets Right
The clearest validation of the two-database split came from watching what each table actually needed under load characteristics, not from a diagram drawn in advance. Signal ingestion needs to survive bursts; incident state needs to survive being trusted. Those are different guarantees, and reaching for one database to do both would have meant compromising on one of them.
If you're building something with a similarly bimodal access pattern (a fast, disposable ingestion path feeding into a slower, durable system of record), this dual-AWS-database pattern on top of Vercel is worth considering as a starting template, not just a hackathon novelty.
Built for the H0 Hackathon (Monetizable B2B App track) using Next.js 16, Aurora DSQL, DynamoDB, and Claude. #H0Hackathon
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article