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.
A SaaS security problem and an agentic-coding reliability problem β this project sits at the intersection.
β² 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.
Three artifacts, one thesis: the prompt is the product, the gates are the proof.
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.
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.
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:
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.
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.
| 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.
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.
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 existThe 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.
Each gate is the contract for its phase. The agent cannot deploy the next stack until the current gate's JSON is green.
| Gate | Proves | Live run |
|---|---|---|
| G0 scaffold | synth = exactly 3 stacks; tests pass | PASS |
| G1 identity | ACCESS token carries tenant_id + email; fail-closed login | PASS |
| G2 isolation β | own-partition works; cross-tenant Query, GSI query, Scan, and untagged AssumeRole all denied | PASS 5/5 |
| G3 API contract | 401 tokenless Β· CRUD Β· 409 conflict Β· PUT preserves omitted fields Β· healthz public Β· log line per request | PASS |
| G4 async | bus β queue β consumer audit write; poison message β DLQ after exactly 3 receives | PASS |
| G5 edge | CloudFront serves site + API; direct S3 = 403; API errors stay JSON; WAF 4 rules | PASS |
| G6 observability | 7 alarms + dashboard; test alarm fires β email arrives β returns OK | PASS |
| G7 cost | budget with 80% ACTUAL + 100% FORECASTED notifications | PASS |
| G8 CI/CD | GitHub OIDC keyless deploy | skipped by parameter |
{"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}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.
Caught by G1. Pre-token trigger now injects both claims into both tokens; core redeployed; green.
Caught by G3 β updating status erased the name. Patch-only update expression.
Caught by G3 via a CloudWatch Logs filter inside the gate. Structured line emitted in a finally block.
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.
CloudFront origin misconfiguration β a state that can only be deleted, never updated. Playbook row matched β delete β fix origin β redeploy β green.
β 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."
Observability isn't a dashboard screenshot; it's a detection loop closed end-to-end.
Each rule is a converted post-mortem. The ones that matter most for anyone running coding agents against real cloud accounts:
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.