L Multi-Tenant-SaaS-Launchpad
AWS Prompt the Planet Β· agentic infrastructure, verified

One prompt. Empty account β†’ multi-tenant SaaS.
Every claim machine-checked.

A ~400-line prompt drives a coding agent to a deployed three-stack serverless foundation where tenant isolation is enforced by IAM, not application code β€” and the agent must stop at nine validation gates, each a script that writes pass/fail JSON evidence.

Open: "Everything you'll see on this page happened today, on a real account. I'll show you the architecture, then the evidence β€” including the moments where the system caught its own builder making mistakes."
1
prompt, ~400 lines
3
CDK stacks Β· 20+ services
8/8
gates green (9th = CI/CD, skipped by param)
5
real defects caught by gates
4
AccessDenied proofs in gate-2
0
human debugging sessions

1Two problems, one entry

A SaaS security problem and an agentic-coding reliability problem β€” this project sits at the intersection.

"Two failure modes motivated this. The left one is a SaaS classic. The right one is the new one β€” the one this competition is actually about."

β–² Multi-tenant isolation is usually theater

Most starters enforce tenancy in application code: every query must remember its WHERE tenant_id =. One missed predicate β€” one rushed PR β€” and tenant A reads tenant B. The control lives in the layer most likely to have bugs.

β–² Agentic IaC is usually unverified

"The agent built it and it deployed" is not evidence. Coding agents hallucinate versions, guess APIs, and β€” as our first run proved β€” can "fix" an IAM trust policy in a way that silently destroys the security guarantee while everything still deploys green.

2What we built

Three artifacts, one thesis: the prompt is the product, the gates are the proof.

"The deliverable isn't the infrastructure β€” anyone can deploy infrastructure. The deliverable is a prompt that makes an agent accountable while building it, plus the evidence format that proves it worked."

1 Β· The prompt

A phased build spec an agent can actually follow: 13 operating rules, a stack-ownership map that makes cross-stack dependency cycles impossible, verbatim snippets for the trap spots, and a 23-row failure playbook the agent must consult before any retry.

2 Β· The gates

Nine validation gates. Each is a script, not a claim β€” it exercises the deployed system, including negative tests (cross-tenant reads, poison messages, tokenless calls), and writes gates/gate-N.json. No JSON file, no progress.

3 Β· The methodology

Every rule exists because a prior agent run violated it and derailed. We ran v1 with a deliberately weak model, audited the wreckage (15 catalogued defects), converted each failure into a rule, a snippet, or a gate assertion β€” then re-ran end-to-end to validate the hardening. Reliability engineering for coding agents, applied where the blast radius is real.

3Inside the prompt β€” 405 lines, dissected

Not a wall of wishes. A quarter of it is operating rules and a failure playbook β€” the agent's runtime, not its task. Every line is load-bearing; here's where they go.

"Let me open the product itself. Four hundred five lines. The striking number: only about half describes what to build. The other half tells the agent how to behave β€” rules, contracts, and a failure playbook. That ratio is the lesson of this project."
OPERATING RULES R0–R12101
STACK OWNERSHIP MAP21
PHASES 0–1 Β· scaffold, identity45
PHASE 2 Β· isolation + trust snippet57
PHASE 3 Β· API layer47
PHASES 4–8 Β· async, edge, obs, cost, CI65
DEFINITION OF DONE12
FAILURE PLAYBOOK Β· 23 rows34
header, params, architecture23

lines per section Β· rules + playbook β‰ˆ 33% β€” the agent-reliability layer Β· green = the isolation phase, the only place given verbatim code

Three design decisions to notice, with the actual text:

1 Β· Rules carry their post-mortemR3, verbatim
R3. **Never hand-type a version number.** Install everything with
    `npm install <pkg>@latest` (or no tag) and let the registry resolve. You do not
    know current versions; every guessed pin in the prior run ([email protected],
    [email protected], [email protected]) failed install. After install, READ
    package.json to learn what you got.

Each rule names the failure that created it. The agent isn't told "be careful" β€” it's told what went wrong last time and what to do instead. The evidence requirement works the same way: R6: gates are scripts, not prose… Claims without a result file don't count.

2 Β· The one place we don't trust generation: verbatim IAMPhase 2, the trust pattern
TenantDataAccessRole β€” USE THIS TRUST PATTERN VERBATIM:
const assumers = ['api-lambda', 'consumer', 'gate-runner']
  .map(n => `arn:aws:iam::${this.account}:role/${appName}-${envName}-${n}`);
const trustCond = { ArnEquals: { 'aws:PrincipalArn': assumers } };
this.tenantDataAccessRole = new iam.Role(this, 'TenantDataAccessRole', {
  roleName: `${appName}-${envName}-tenant-data-access`,
  assumedBy: new iam.AccountPrincipal(this.account).withConditions(trustCond),
});
…plus an explicit sts:TagSession statement, then:
NEVER trust `lambda.amazonaws.com` here: at runtime the AssumeRole caller is the
execution-role SESSION, not the Lambda service β€” service-principal trust both
fails at runtime and would let any Lambda in the account mint tenant credentials.

A prior run "fixed" this exact trust policy into something that deployed green and silently destroyed the guarantee. So the prompt stops delegating here: the security-critical pattern is supplied, with the anti-pattern named and explained. Generate the boilerplate; dictate the invariant. Compare the deployed core-stack.ts in the repo β€” it matches character for character.

3 Β· The playbook the agent must consult before any retry3 of 23 rows
| Symptom                              | Likely cause                | Fix |
| Deploy: "Invalid principal in policy"| trust names a not-yet-existing role | principals are existence-checked; conditions are not β†’ AccountPrincipal + PrincipalArn condition |
| DLQ count still 0 after one check    | polled too early            | 3 receives Γ— 180s visibility β‰ˆ 9–12 min; poll 30s up to 15 min |
| API JSON errors arrive as 200 HTML   | distribution-wide errorResponses | CloudFront Function URI-rewrite on default behavior |

Paired with rule R7 β€” match a row, state root cause in one paragraph, max two fix attempts per error β€” this is what replaced blind retry loops. In the live run, the dead edge stack was recovered through exactly this table. The full prompt is in the repo at docs/PROMPT.md.

4The isolation mechanism

Standard serverless edge-to-data path, one unusual property: cross-tenant access isn't a bug you avoid β€” it's a permission that doesn't exist.

"Walk one request with me, top to bottom. The tenant ID is born in Cognito β€” fail-closed, no tenant, no token. The Lambda trades it for STS credentials tagged with that tenant. And DynamoDB's policy only allows keys starting with that tenant's prefix. App code also scopes its queries β€” but IAM is the backstop. Even buggy code can't cross."
Browser ── CloudFront (+WAF, TLS) ──┬── S3 site (private, OAC)
                                    └── /v1/* β†’ HTTP API ── Cognito JWT authorizer
                                                  β”‚
                                             API Lambdas ── EventBridge ── SQS (+DLQ Γ—3) ── consumer
                                                  β”‚
   tenant_id claim (injected by fail-closed pre-token trigger β€” no tenant, no token)
                                                  β”‚
   STS AssumeRole + session tag tenant_id=<from JWT>   ← scoped creds, 15 min, cached
                                                  β”‚
   DynamoDB policy:  dynamodb:LeadingKeys = "TENANT#${aws:PrincipalTag/tenant_id}#*"
                     on table AND every GSI Β· Scan permission does not exist

The trust policy itself is hardened: account principal constrained by aws:PrincipalArn, with sts:TagSession explicit β€” because the obvious alternative (trusting the Lambda service principal) both breaks at runtime and would let any Lambda in the account mint tenant credentials.

5The nine gates β€” and the money gate's receipt

Each gate is the contract for its phase. The agent cannot deploy the next stack until the current gate's JSON is green.

GateProvesLive run
G0 scaffoldsynth = exactly 3 stacks; tests passPASS
G1 identityACCESS token carries tenant_id + email; fail-closed loginPASS
G2 isolation β˜…own-partition works; cross-tenant Query, GSI query, Scan, and untagged AssumeRole all deniedPASS 5/5
G3 API contract401 tokenless Β· CRUD Β· 409 conflict Β· PUT preserves omitted fields Β· healthz public Β· log line per requestPASS
G4 asyncbus β†’ queue β†’ consumer audit write; poison message β†’ DLQ after exactly 3 receivesPASS
G5 edgeCloudFront serves site + API; direct S3 = 403; API errors stay JSON; WAF 4 rulesPASS
G6 observability7 alarms + dashboard; test alarm fires β†’ email arrives β†’ returns OKPASS
G7 costbudget with 80% ACTUAL + 100% FORECASTED notificationsPASS
G8 CI/CDGitHub OIDC keyless deployskipped by parameter
"This next screenshot is the one I'd ask you to remember. Tenant-A's credentials against tenant-B's data. Look at the actual fields β€” those are raw error strings from the deployed table: denied on the table, denied on the index β€” the classic leak path β€” denied on Scan, and denied when the role is assumed without a tenant tag at all. Four real AccessDeniedExceptions. You can't write a paragraph that proves this; you can only run it."
E1Gate 2 β€” the isolation prooffour raw AccessDeniedExceptions
Gate 2 β€” the isolation proof
Tenant-A credentials vs tenant-B data. The actual fields hold the raw STS/DynamoDB error strings from the deployed table β€” denied on the table, denied on the GSI, denied on Scan, denied without a session tag. Click to zoom.
{"gate":2,"checks":[
 {"name":"own-partition write+read as t-aaaaaaaa","actual":"item readable","pass":true},
 {"name":"cross-tenant Query","actual":"AccessDeniedException","pass":true},
 {"name":"cross-tenant GSI1 Query","actual":"AccessDeniedException","pass":true},
 {"name":"Scan with tenant creds","actual":"AccessDeniedException","pass":true},
 {"name":"AssumeRole without tenant_id tag","actual":"AccessDeniedException","pass":true}],"pass":true}

6The live run β€” the gates caught the builder

June 10, 2026, clean account, agent running detached on an EC2 build host under the instance role. Five real defects, each caught by a gate, fixed via the playbook, re-proven green β€” zero human debugging.

"This is the part I want to be honest about, because it's the strongest part: the agent made mistakes today. Every one of these five was caught by a gate, not by a person."
1

Access token missing the email claim

Caught by G1. Pre-token trigger now injects both claims into both tokens; core redeployed; green.

2

PUT clobbered omitted fields

Caught by G3 β€” updating status erased the name. Patch-only update expression.

3

Log contract not emitted

Caught by G3 via a CloudWatch Logs filter inside the gate. Structured line emitted in a finally block.

4

Consumer role violated the naming contract

Caught by G4 β€” the auto-named role wasn't in the tenant role's trust, audit writes denied. Fixed-name role per the cross-stack contract.

5

Edge stack dead in ROLLBACK_COMPLETE

CloudFront origin misconfiguration β€” a state that can only be deleted, never updated. Playbook row matched β†’ delete β†’ fix origin β†’ redeploy β†’ green.

"And here's what 'green' looked like. The site, live through CloudFront and WAF. The API on the same domain answering in JSON. A tokenless call getting an honest 401 β€” not a rewritten success page. And the stack table, where the timestamps tell the recovery story on their own: core and api say UPDATE β€” those are the gate fixes β€” and edge says CREATE, because the playbook deleted the dead stack and rebuilt it."
E2The site, live through CloudFront + WAFd12p64mor0h8l0.cloudfront.net
The site, live through CloudFront + WAF
The DevTools Network panel is the provenance: the document is fetched from d12p64mor0h8l0.cloudfront.net itself β€” a 304 revalidation against the edge cache. Private S3 origin via OAC; direct bucket access returns 403 (gate-5).
E3Same domain: healthz 200 JSON Β· tokenless 401 Β· gate manifestone take, live URL
Same domain: healthz 200 JSON Β· tokenless 401 Β· gate manifest
healthz answers in JSON through the edge; the tokenless call returns 401 β€” the SPA fallback is a CloudFront Function scoped to the default behavior, so API errors can never be rewritten into 200 HTML. The ls shows all eight gate files.
E4Three stacks β€” the timestamps tell the recovery storypre-teardown
Three stacks β€” the timestamps tell the recovery story
core UPDATE 10:20 (gate-1 fixes) Β· api UPDATE 10:46 (gate-3/4 fixes) Β· edge CREATE_COMPLETE 10:39 β€” created fresh because the playbook deleted the dead ROLLBACK_COMPLETE stack and redeployed it corrected.

βœ“ The loop that matters

gate β†’ diagnose β†’ fix β†’ re-prove. That loop is the difference between "an agent deployed something" and "an agent shipped something verifiable."

7Day-2 proof β€” monitoring that actually detects

Observability isn't a dashboard screenshot; it's a detection loop closed end-to-end.

"Three exhibits. The dashboard with real traffic β€” that spike is the gate-3 request loop, the DynamoDB bursts are the isolation tests. The alarm list β€” six OK, and one correctly firing: that's gate-4's poison message sitting in the dead-letter queue. The monitoring detecting exactly what it was built to detect. And the email β€” read the reason line: 'Gate 6 synthetic alarm test.' The alert path reaches a human inbox, provably."
E5The dashboard, with real trafficlaunchtest-staging-overview
The dashboard, with real traffic
The API spike is gate-3's 25-request loop; the DynamoDB bursts are the isolation tests; the SQS panel shows gate-4's queue activity with the DLQ line. Not an empty dashboard.
E6Seven alarms β€” one correctly firinga true positive, not a flaw
Seven alarms β€” one correctly firing
Six OK, one In alarm: dlq-not-empty β€” gate-4's deliberately poisoned message sitting in the dead-letter queue. An all-green list proves alarms exist; a true positive proves they detect.
E7The alarm email β€” the loop reaches a humaninbox, 13:52 UTC
The alarm email β€” the loop reaches a human
The state-change reason in the body literally reads "Gate 6 synthetic alarm test", tying the inbox artifact to the gate that produced it.
E8The receipts, re-verified on a second machinegates 0–7, all PASS
The receipts, re-verified on a second machine
The evidence tarball pulled from the build host to a laptop and every gate JSON re-parsed: eight verdicts, eight PASS. Gate-8 (CI/CD) skipped by parameter; the OIDC keyless pipeline is specified in the prompt.

8Agent reliability engineering β€” what's in the prompt

Each rule is a converted post-mortem. The ones that matter most for anyone running coding agents against real cloud accounts:

"If you take one section back to your teams, take this one. None of these rules are theoretical β€” each one is a failure we watched happen, converted into a constraint."
R0 workspaceAgent sandboxes bind to the directory they were started in. Verify writability, confirm the build host + IAM identity before anything. One build host β€” laptop-side "help" under an admin user masks instance-role permission gaps.
R3 versionsNever hand-type a version number. Models hallucinate pins; install untagged, read package.json afterwards.
R4 verify-firstBefore using an unfamiliar CDK prop, grep the installed type definitions β€” synth-failure-driven discovery wastes a full cycle per guess.
R5/R6 gatesOne named stack per deploy, never --all. Gates are scripts that write JSON; prose claims don't count.
R7 playbookOn failure: match a playbook row, state root cause in one paragraph, max two fix attempts β€” no blind retry loops.
R12 resumeSessions die. The resume protocol reconciles from CloudFormation + the gate files. Our run survived an orchestrator hitting its usage limit mid-build precisely because the build ran detached with machine-readable state on disk.
ownership mapObject references flow forward only; backward references are constructed ARN strings (trust conditions are string-matched, never existence-checked). One rule dissolves both the dependency-cycle and the deploy-ordering trap.

9Why this matters

"Closing thought. The field is full of prompts that generate guardrails. Almost none ship proof the guardrails hold. That gap is this entry."

For SaaS teams: "show us tenant separation" gets answered with gates/gate-2.json, not a paragraph. Session-tagged STS + LeadingKeys, fail-closed claims, no-Scan β€” lift-and-shift reusable.

For agentic coding: a working template for making agents accountable on real infrastructure β€” phase contracts, machine-checkable gates with negative tests, failure playbooks, resume protocols, identity discipline. The weak-model first run wasn't a failure; it was the test suite for the prompt.

The prompt is the product.
The gates are the proof.