Solution Architecture  /  Cloud Control Plane  /  Ports & Adapters

Six methods,
one cloud at a time.

An app that manages cloud infrastructure almost always hard-codes which cloud, and worse, which account. Mine did. I fixed both with a six-method TypeScript interface and one change to where credentials come from. The interesting part is not what I built — it is how much I refused to build.

BEFORE · vendor + tenancy coupling import @azure/arm-compute import @azure/arm-storage … 20+ @azure/* packages DefaultAzureCredential() × ~10 sites ← process.env ⇒ ONE subscription, forever AFTER · one seam, per-request credential Route Handler /api/connections/[id]/… rateLimit → requireRole → confirm → resolveProvider(id, owner) REST · local JWT + RBAC Connection vault AES-256-GCM · owner-scoped CloudProvider · 6 methods azure ✓ · aws ⌛ · gcp ⌛ Azure ARM ClientSecretCredential · this acct AuditLog · append-only every attempt, both ways FROZEN, still working: Express azure_api · 62 routes NOTHING MIGRATED · A SEAM WAS INSERTED · THE OLD THING WAS LEFT ALONE

There is a particular way that infrastructure software boxes itself in. It hard-codes not just which cloud it talks to, but which account. Mine did both. Twenty-plus @azure/* packages, and DefaultAzureCredential() constructed in about ten separate places, each of them reading the same process-wide environment variables. That is not one coupling problem. It is two.

The first is a vendor coupling: every call goes straight to an Azure SDK, so a second provider is a rewrite of every call site. The second is quieter and worse — a tenancy ceiling. Because credentials live in the environment, the app is not "an Azure management app." It is "a management app for exactly one Azure subscription, chosen at deploy time." A user cannot connect their account. There is no product in that. This is the story of the seam I inserted to fix both, and the far larger surface I deliberately refused to abstract.


01 · THE PROBLEMTwo couplings, stacked

I want to be honest about where this started, because the fix only makes sense against the mess it replaced. Quoting the Context section of the architecture decision that opens this work, ADR-004:

ADR-004 · Context

The Express API pulls in 20+ @azure/* packages. DefaultAzureCredential() is constructed in ~10 separate sites, each reading process-wide env vars. There is one set of credentials per process — the app can only ever talk to the single subscription configured in its environment. No Connection model, no per-tenant credential storage, and no audit log.

Problem A is vendor coupling. Every cloud call reaches straight into a vendor SDK. Adding a second provider means touching every one of them.

Problem B is the tenancy ceiling, and it is the one that actually hurts. Credentials sit in process.env; a serverless function reads them at cold start. So there is no multi-account story, no multi-tenant story, and therefore no product. Two more absences turn out to be the same fix: there was nowhere to put a credential that isn't an env var, and there was no action ledger. The existing Event model was telemetry, not an audit trail — and "we already log things" is the most common way an audit requirement gets quietly failed.

For honesty about the starting scale: 62 API routes across six route files, zero auth on any of them, managing live Azure infrastructure. That was logged as critical gap #1 and fixed before any of this — all 61 non-auth routes put behind requireAuth. The control-plane work here starts after that cleanup, on a codebase I had already audited and graded honestly: backend 2.5 out of 5, cross-lane coherence 2 out of 5. I mention the grade because a system is easier to trust when its architect writes down what is wrong with it first.

02 · THE WRONG TURNThe multi-cloud framework everyone reaches for

The reflex answer to "make it multi-cloud" is to reach for Pulumi, Terraform, Crossplane, or libcloud. It feels responsible. It is the wrong shape. From ADR-004's Alternatives Considered:

Rejected · IaC engines

These are infra-provisioning / IaC engines, not lightweight runtime control SDKs. They add a large operational dependency, a state model, and a learning curve far exceeding "start/stop a VM, read cost." Massive over-fit for the v1 capability surface.

The category error is the whole point. An IaC engine's job is to converge desired state. My job was to press stop on a running machine. Those are not the same shape, and adopting a state-machine to do an imperative thing is exactly how a small seam quietly becomes a platform migration.

03 · THE WORSE WRONG TURNAbstract all sixty-two routes

The more seductive mistake was to put the whole existing Azure surface behind the new port — all eight route files, roughly twenty service domains, sixty-two routes. This is the trap, and the ADR's rationale for avoiding it is the single best line in the document:

A port is only worth its weight if every method is implementable by every future adapter.

"List compute, read cost, control a VM" maps cleanly onto Azure, AWS, and GCP. The twenty-service Azure-specific surface does not. Forcing it through a port would produce a leaky, Azure-shaped interface that no AWS adapter could honor. So the ADR did the thing most refactor plans never do: it declared a v1 Scope Reset and froze the legacy surface in place rather than porting it. "v1 ships the portable core; the Azure-specific richness stays in the frozen Express layer for anyone who needs it." And — this is the part I am proudest of — it wrote down the cost of that choice as a Negative, not a footnote.

ADR-004 · Negative (recorded, not hidden)

Two cloud-access paths coexist: the new port (Next app, TypeScript, vault-credentialed) and the frozen Express layer (env-credentialed). Until the Express surface is retired or migrated, this is a known, documented split — not accidental drift.

That sentence is the thesis of the piece in miniature. Drift you have written down and bounded is a decision. Drift you haven't is rot.

There was a third refusal, and I made it by name. Sitting right next to the control plane was a live, useful subsystem — the Powerhouse Integration Hub, a content-integration landing zone with a service-health grid and a read-only Cosmos console. Every instinct says "abstract that too, it's cloud work." ADR-004 amended itself to say no, bluntly: the Integration Hub is explicitly de-scoped, a data-exploration concern categorically different from a cloud control plane, and forcing it through CloudProvider would corrupt the port with content-API and Cosmos-query methods no AWS or GCP adapter could ever honor. Any future "abstract the Integration Hub" request is a separate ADR, not an amendment to this one. Two systems, both cloud, both mine, kept apart on purpose. That discipline is what the rest of this is about.


04 · THE KEY INSIGHTSix methods, and the credential moves

The unlock has two halves, and neither is large. The first: the entire vendor abstraction is six methods. Verbatim from providers/types.ts:

export type ProviderKind = 'azure' | 'aws' | 'gcp';   // only 'azure' implemented in v1

export interface CloudProvider {
  readonly kind: ProviderKind;

  // connectivity / credential validation
  verifyConnection(): Promise<{ ok: boolean; identity?: string; error?: string }>;

  // read plane
  listComputeInstances(): Promise<ComputeInstance[]>;
  getCostSummary(period: { from: string; to: string }): Promise<CostSummary>;

  // control plane (VM lifecycle) — all audited + confirm-gated upstream
  startInstance(id: string): Promise<void>;
  stopInstance(id: string): Promise<void>;
  restartInstance(id: string): Promise<void>;
  resizeInstance(id: string, size: string): Promise<void>;
}

export interface CloudProviderFactory {
  forConnection(cred: CloudCredential): CloudProvider;   // one request, one credential
}

Three provider-neutral verbs — connect, list inventory, control. The names are the v1 realisation; the verbs are the contract.

The discipline that keeps this honest is that each verb was pinned to a plausible AWS realisation at design time, precisely so the port cannot quietly drift into an Azure shape. This mapping is straight out of ADR-004:

Capability (provider-neutral)Port method(s)Azure adapter (v1, shipped)AWS adapter (v2, illustrative)
connectverifyConnection()ClientSecretCredentialgetToken + resourceGroups.list()STS:GetCallerIdentity
list inventorylistComputeInstances(), getCostSummary()virtualMachines.listAll() + Cost ManagementEC2:DescribeInstances + Cost Explorer
controlstart / stop / restart / resizeComputeManagementClient begin* long-running opsEC2:Start/Stop/Reboot/ModifyInstanceAttribute

Any v2 adapter that can honor those three verbs slots in behind CloudProviderFactory.forConnection with zero handler, dashboard, or audit changes. That is the entire point of the port. To be exact about the state of play: aws and gcp are reserved members of the union type and nothing more — the factory throws on them today.

And here is the actual reveal — the credential moves. The port alone buys you nothing if the adapter still reads process.env. The whole multi-account capability comes from one function:

export async function resolveProvider(id: string, ownerEmail: string | null): Promise<CloudProvider> {
  await dbConnect();
  const doc = await Connection.findOne({ _id: id, createdBy: ownerEmail })   // owner-scoped ⇒ IDOR-safe
    .select('+secret')                                                       // sealed envelope is select:false
    .lean();
  if (!doc) throw AppError.notFound('Connection not found');
  const cred = decryptJson<CloudCredential>(doc.secret);                     // open in memory
  return providerFactory.forConnection(cred);                                // …and discard
}

Ten ambient-environment credential sites, replaced by one owner-scoped lookup that opens a sealed envelope and throws the plaintext away.

That is the change that turned a single-subscription demo into a multi-account product, and it is roughly a dozen lines. Everything else in this piece is consequence. The adapter constructor makes the shift visually obvious, and its own header comment says it out loud — built from a decrypted, per-connection credential, not process.env and not the old DefaultAzureCredential sites:

constructor(cred: AzureCredential) {
  this.credential = new ClientSecretCredential(cred.tenantId, cred.clientId, cred.clientSecret);
  this.subscriptionId = cred.subscriptionId;
}

The port makes "which cloud" a detail. The resolver makes "which account" a detail. Together they are the product.


05 · THE ARCHITECTUREWhat actually sits in the request path

Here is the shape of a single request, end to end. The read the diagram should make obvious is that the port is the only new thing in the path, and the frozen legacy surface is still sitting there on purpose. Nothing was migrated. A seam was inserted, and the old thing was left alone.

ONE REQUEST · connectionId in hand NEXT.JS ROUTE HANDLER · REST 1 · enforceRateLimit 2 · requireRole('admin','operator') 3 · parseBody(zod) confirm: z.literal(true) 4 · resolveProvider(connectionId, owner.email) auth: local JWT + RBAC · no DefaultAzureCredential CONNECTION · MongoDB, owner-scoped secret: AES-256-GCM envelope · select:false meta: display-safe ids only CLOUDPROVIDER · 6 methods providerFactory.forConnection(cred) azure ✓ aws ⌛ gcp ⌛ union type · factory throws on aws / gcp in v1 ClientSecretCredential — this account only AZURE ARM Compute · Resources · Cost begin* long-running ops AUDITLOG append-only · write-once success AND failure FROZEN, UNTOUCHED, STILL WORKING Express azure_api — 62 routes, env-credentialed, v1 scope reset · fallback + reference, not deleted decryptJson → discard port → adapter → ARM
The settled request path. Rate limit, then role, then a typed confirm, then the resolver opens the owner's sealed credential and builds a provider scoped to that one account. The adapter talks ARM; an immutable audit row lands before the response leaves. The vendor name appears in exactly one file. The old Express surface sits below, on purpose.

In words, the six-beat flow: a user stores a cloud account with POST /api/connections and the secret is sealed on the way in and never comes back out. A request arrives carrying its connectionId; rate limit first, then requireRole(user, 'admin', 'operator'). The body is Zod-parsed, and for anything destructive confirm: z.literal(true) is part of the schema — so "I meant it" is a type, not a convention. resolveProvider opens the AES-256-GCM envelope, builds the adapter, and drops the plaintext. The adapter calls ARM with a credential scoped to that subscription. Success or failure, an immutable AuditLog row is written before the response leaves.


06 · THE VAULTFour layers, and the tradeoff I kept

This is the section that earns the piece its credibility, because the security posture lives here rather than in a diagram. The envelope is AES-256-GCM under a single app master key — 32 raw bytes, base64, from the environment, never in the repo. GCM is authenticated encryption, so tampering fails on final(): integrity is verified on decrypt, not assumed. The code fails closed and fails loudly — a missing key does not degrade to plaintext, it throws with the remedy in the message (generate one with openssl rand -base64 32). And it leaves room for a rotation it hasn't done yet, by carrying an alg and a per-record envelope shape so a future key id slots in without a migration. Writing down the shape of a migration you haven't performed is the same instinct as writing down the coexistence cost in section 03.

THE CREDENTIAL · request in → plaintext discarded request connectionId + owner findOne owner-scoped .select('+secret') AES-256-GCM open verify, then in memory ClientSecretCred. this account only ARM call scoped to subscription DISCARD plaintext gone FOUR DEFENCES ON THE CONNECTION MODEL 1 · secret select:false — forgotten projection can't leak it 2 · publicConnection() — allowlist projector, not delete-list 3 · meta holds display-safe ids only — queryable, non-secret 4 · owner scoping in the query — IDOR made unrepresentable MASTER KEY seals everything · RBAC of the service principal is the ultimate backstop
The credential opens for exactly one call, then it is gone. Four defences layered on purpose: a select:false secret, an allowlist projector, display-safe meta only, and owner scoping in the query itself. A wrong id and someone else's id produce the identical 404 — IDOR isn't defended against, it is made unrepresentable.

The four defences are worth naming because they are layered deliberately. The secret is select:false, so a forgotten projection can't leak it into a response — leaking requires an explicit .select('+secret'), which appears in exactly one place. The public view is built from an allowlist projector, not a delete-list: the secret can never leak because it is never read there. The distinction between "I removed the dangerous field" and "I only copied the safe ones" is the whole game. meta holds display-safe identifiers only. And owner scoping lives in the query, not a middleware check.

Now the tradeoff I kept, stated plainly. One master key seals everything — cloud credentials and LLM keys alike. That is a deliberate simplicity call, and it means the blast radius of a leaked CONNECTION_ENC_KEY is the whole vault. The ADR logs it as a named risk — "the vault is only as safe as the app key" — with mitigation and no pretence that it's solved. The ultimate backstop is not the app at all: the stored service principal's own Azure RBAC bounds everything. The app can never provision beyond what the credential is permitted.


07 · THE DETAIL THAT SEPARATES REAL FROM DEMOI audited my own audit log, and it failed

The ledger, AuditLog, is append-only by construction, not by policy: timestamps: { createdAt: true, updatedAt: false }. There are no update or delete paths — entries are write-once, roughly the shape of CloudTrail. One subtlety is the correct call and the non-obvious one: writeAudit() never throws into the caller's flow, because auditing must not mask the underlying action result. A failing ledger must not turn a successful stop into a 500 — but it also means a silently broken ledger is possible, which is exactly what bit me.

The reference endpoint, POST /api/connections/[id]/vms/control, is short enough to show almost whole. It is the whole posture made concrete:

export const POST = route(async (req, { params }) => {
  await enforceRateLimit(req, 'inthub');
  const user = requireRole(getUserFromRequest(req), 'admin', 'operator');
  const { action, vmId, size } = await parseBody(req, vmControlBody);   // includes confirm: z.literal(true)

  const provider = await resolveProvider(params.id, user.email ?? null);
  const audit = { actor: user.email, action: `vm.${action}`, provider: provider.kind,
                  connectionId: params.id, resourceId: vmId, params: size ? { size } : {} };
  try {
    switch (action) { /* start | stop | restart | resize → the port, never the SDK */ }
  } catch (err) {
    await writeAudit({ ...audit, outcome: 'failure', error: (err as Error).message });   // failures too
    throw new AppError(`VM ${action} failed: …`, 502, 'UPSTREAM_ERROR');
  }
  await writeAudit({ ...audit, outcome: 'success' });
  return NextResponse.json({ success: true, action, vmId, status: 'accepted' });
});

Three details in there each earn a sentence. Failures are audited — most audit implementations record only what worked, but the interesting forensic signal, repeated denied attempts, lives in the failures. status: 'accepted', not 'done' — Azure VM power transitions take 30–60 seconds and more, so the adapter awaits acceptance of the long-running operation, not completion, because the transition is too long to block a serverless request; the response tells the truth about what happened. And "stop" means deallocatebeginDeallocate, not power-off, because someone who clicks stop wants the bill to stop. That's a product decision expressed in one method call.

The honest beat

The requirement was an append-only audit log on every mutation, with confirm-gating and operator/admin RBAC on all control endpoints. A scheduled security sweep enumerated every mutation route and graded each for requireRole, confirm, and writeAudit. The finding, verbatim:

Coverage sweep · finding

The task's literal breadth ("every mutation / all control endpoints") is not yet fully met: several other privileged mutations are RBAC-gated but not written to the audit ledger, and the two destructive vault deletes are neither audited nor confirm-gated.

Concretely: fifteen mutation exports across fourteen route files, and exactly one — vms/control — was complete. Five privileged credential mutations were RBAC-gated but unaudited. Two destructive deletes had no confirm gate at all. The failure mode is instructive and generalises: the audit log was built as part of the VM-control feature, so it was wired everywhere VM control went and nowhere else. The ledger was excellent. Its coverage was a feature-shaped accident. The ADR had even predicted the mechanism — "if a control action is added that bypasses the port, it escapes the audit log" — it just didn't anticipate that the vault's own mutations were already outside the port.

Reported honestly: the gap was written up as a line-precise coverage matrix, re-swept twice more on a schedule with dated NO DRIFT rows against a real HEAD, then fixed — writeAudit on success and failure for all five, plus a new auditCoverage.test.ts, verified in Docker (tsc clean, 13 suites and 195 tests green) and shipped as azure_app PR #32. And one thing was deliberately not fixed: the confirm-gate on the two destructive DELETEs. Adding a required confirm: true to an existing DELETE is a breaking API change needing a coordinated frontend release, so it was queued rather than smuggled in. "I found it, I fixed the part that was safe to fix now, and I wrote down why the rest waits" is a better ending than a clean sweep.

Why it matters

A narrow contract you can enumerate is one a machine can grade. The reason the gap was found at all is that "every mutation route" is a grep, and "audited + confirm-gated + RBAC" is three booleans per route. The bug was real; the thing that caught it was the same explicitness that made the port cheap.


08 · THE PATTERN THAT REPEATEDThree ports, one vault, one refusal

Here is the strongest structural argument in the whole system. Once the shape existed — a narrow neutral port, one concrete adapter, an encrypted owner-scoped vault, a decrypt-and-discard resolver, and an audit trail — it got reused for two completely different domains, and refused for a third.

THE SHAPE narrow port · one adapter · owner-scoped vault · decrypt-and-discard · AuditLog · RBAC CloudProvider ✓ shipped control what exists · ADR-004 6 methods · Connection vault azure ✓ · aws / gcp ⌛ LlmProvider ✓ shipped reason over text · ADR-006 2 methods · LlmKey vault anthropic ✓ · openai ✓ · google ⌛ CloudDeployer ⌛ designed provision what doesn't · ADR-005 same vault, same resolver azure only · unbuilt INTEGRATION HUB ⛔ DELIBERATELY NOT A PORT its own routes · its own credential · its own RBAC · a separate ADR refused, by name
One shape, reused twice and refused once. The refusal is the load-bearing part. Cramming content queries into CloudProvider, or deploy() into it, would corrupt the port with methods no other adapter could honor — the exact leak the narrow surface exists to prevent.

The LLM port is the same shape in a different universe. The Blueprint Pipeline needs an LLM at several stages, and the naïve move is to hardwire one vendor's SDK into each call site. Instead: a two-method port (complete, completeStructured) and an LlmKey vault sealed with the same AES-256-GCM helper under the same master key, owner-scoped the same way, resolved by the same decrypt-and-discard pattern. The Consequences section says it best — it "reuses shipped infrastructure … no new security surface to design."

Two adapter decisions there resist a lazy symmetry. Claude gets a native adapter, not a shim: a lowest-common-denominator chat-completions shim would throw away adaptive thinking, structured output_config.format, and current model ids, so Claude is the default and gets first-class treatment. Meanwhile a single openai adapter covers a whole ecosystem via baseUrl — OpenAI, DeepSeek, Groq, Together, Mistral, Ollama, local servers — with one file; model is required for that kind because there is no safe default to assume, and the constructor throws without it. Honest defaults over convenient ones. google (Gemini) is reserved in the union and throws "reserved but not implemented in v1"; adding it is one adapter file and one case. And a lovely two-layer guardrail worth calling out as a pattern in its own right: the adapter guarantees parseable JSON (parse-and-retry-once); the caller guarantees schema conformance (Zod-validate-with-one-repair, feeding the validation issues back to the model). Two failure modes, two owners, neither pretending to cover the other.

And the third port was designed by refusing to extend the first. When the next feature needed provisioning, the obvious move was to add preview()/deploy()/teardown() to CloudProvider. Rejected — that would force every provider adapter to ship a full deployment engine just to list a VM. Runtime control of a running resource and provisioning of new infrastructure are different concerns with different blast radius, different inputs, and different cadence, so they were kept as siblings. One accuracy note I insist on: CloudDeployer is designed, not built. ADR-005's own status line reads "Accepted · Partially implemented." The port, the AzureDeployer, the Deployment model, what-if preview, and the confirm-token bound to plan version plus what-if hash are all unbuilt. It is an accepted design with a shipped front-end feeding it — never running code.


09 · THE NEIGHBOUR I DIDN'T ABSORBThe Integration Hub

The system I refused to abstract deserves its own section, because it is genuinely interesting engineering and because it is where real Powerhouse infrastructure grounds the piece. The Integration Hub is a landing zone surfacing the museum's content-integration plane — Azure subscription phm-dev-inthub, region australiaeast. Live components, verified at build time: a Linux Function App content API (funcPhmIntHubContentDxpDev), a Cosmos DB SQL-API account (cosmosphminthubdev, Session consistency), three ingest functions for Momentus, Tessitura and Sanity, three Event Grid CloudEvents topics, an APIM gateway, and Auth0 for identity.

The insight that shaped the build: the content API only HTTP-exposes the aggregated events; the richer per-source data — venues, rooms, productions, performances, zones, programs — is only reachable via direct Cosmos queries. So the Integration Hub backend has to talk to both the content API over HTTP and Cosmos directly over SQL. That is a coupling to two data planes that no cloud-control port could ever model cleanly, which is precisely why it stayed out of the port.

The read-only SQL console is defence-in-depth done right, and the honest framing belongs here verbatim: "Cosmos's query API is read-only by nature; the guard below is defense-in-depth + enforces the SELECT shape and a hard row cap." assertReadOnlySelect() enforces a single statement (a trailing semicolon is stripped, any remaining one rejects, which blocks statement stacking), a leading SELECT, and a forbidden-keyword list matched on whole-word boundaries so column names like created or updatedField aren't flagged by CREATE/UPDATE — plus a hard row cap and mandatory paging.

The correct posture

A blog that says "I validated the SQL so it's safe" is worse than one that says "the data plane already can't write; I validated the SQL anyway, and here's the boundary that actually holds." The guard is not the security boundary — it is a second one. The Cosmos connection string and content-API base URL live only in server env, never shipped to the browser. And the "five rows, then More" pagination is built on continuation tokens with results appended rather than replaced, so the operator accumulates context instead of losing it.


10 · WHERE IT WAS HEADINGADR → plan → IaC → deploy

The feature the ports exist to serve is the most ambitious thing in the repo: a user describes the architecture they want as an ADR, and the app turns that ADR into deployed cloud infrastructure, with a human review gate before anything is provisioned. I'll be precise about what is real — most of this is an accepted design, and only the front door has shipped.

BLUEPRINT PIPELINE · teal = shipped · dashed = designed 1 · ADR INTAKE upload PDF/MD/DOCX ✓ SHIPPED · BP-2 2 · CONTEXT account, region, env ⌛ designed 3 · PLAN provider-NEUTRAL resource graph ⌛ the pivot 4 · IaC gate: bicep build ⌛ designed 5 · REVIEW GATE gate: what-if · human ⌛ designed 6 · DEPLOY ARM · stored cred · audited ⌛ designed Azure → Event Grid / Blob / Functions / APIM AWS → EventBridge / S3 / Lambda / API GW (same plan) swap the IaC job + deployer adapter — nothing upstream changes THE AI NEVER TRIGGERS A DEPLOYMENT · A HUMAN DOES
Describe once, deploy anywhere — the same thesis, one layer up. Stage 3 emits a provider-neutral resource graph; only stage 4 knows it's emitting Azure Bicep. The bicep build gate and the mandatory what-if preview are literal gates. Only stage 1 (BP-2) has shipped; stages 3–6 are an accepted design.

The pivot idea is what makes this architecture rather than a prompt: an ADR is a cloud-agnostic statement of intent; the architecture plan is a provider-neutral resource graph — a message bus, object-store tiers, serverless transforms, a managed API gateway — that Azure maps to Event Grid, Blob, Functions and APIM, and that AWS would map to EventBridge, S3, Lambda and API Gateway. The AWS path is "swap the IaC-generation job and the deployer adapter," with no changes to intake, elicitation, plan generation, the review gate, or the data model.

The quiet best idea is the ADR template. It splits every ADR into narrative fields (the "why," reasoning context for the model) and machine-actionable fields emitted as a typed block. The rationale is the sharpest sentence in the plan: "the LLM is bad at being a deterministic config source and good at narrative reasoning. By keeping the deploy-driving facts in a typed block, the plan generator validates them with Zod and uses the narrative only as reasoning context. This bounds AI blast radius." The non-negotiable guardrails follow from that: generated Bicep is validated with bicep build before it is ever shown as ready; what-if is mandatory before deploy; the AI never triggers a deployment, a human does; guardrail cross-checks like budget and allowed regions are enforced by deterministic code, not the LLM; and the confirm-token is bound to plan version plus what-if hash, so approving plan v2 does not authorize deploying v3.

What actually shipped is BP-2, the ingestion front door, and it exercises the whole LLM port: POST /api/blueprints/adrs/upload → RBAC → multipart parse (15 MB cap) → text extraction by mime (Markdown as UTF-8, PDF via pdf-parse, DOCX via mammoth, all pure-Node and dynamically imported so the markdown path never loads the binary libs) → resolveLlmProvidercompleteStructured against a JSON Schema → Zod-validate-with-one-repair → raw bytes streamed to GridFS → persist → 202. Two details are worth keeping: the raw bytes are retained so a future re-extraction with a better model needs no re-upload — the Bronze-layer, keep-the-raw-input instinct — and the GridFS key never leaves the server, redacted in the public projector and stripped in the model's toJSON. Belt and braces, because one of those layers will eventually be forgotten. Everything past BP-2 — context elicitation, plan generation, IaC generation, review gate, deploy — is specced and not built. So are the remaining CloudProvider tasks: real inventory and cost, the control dashboard, the install bundle.


11 · HOW IT WAS BUILTADR-first, agent-built, self-auditing

This is the part I'd share the piece for. The system was not just designed with ADRs; it was built by an autonomous multi-agent pipeline that the ADRs governed, and then re-audited on a schedule by that same pipeline. The architecture governed itself.

The mechanics are all verifiable in the repo. A parent orchestration repo, AZURE, holds the ADRs, specs, plans and state and has no build target of its own; the deployable code lives in azure_app, and every spec carries frontmatter saying exactly that. The specs are written to be executable by an agent with no prior context — each is headed "this document is the complete build brief," with a "current state (frozen, do not re-derive)" block, an interesting artefact of writing for a reader whose main failure mode is confidently re-inventing what already exists. Some specs even correct the task that spawned them: one opens by noting the fleet task called the methods listResources/getCost while the shipped port says listComputeInstances/getCostSummary — "use the shipped names, do not rename the port." The spec defends the contract against its own work order.

A scheduled headless runner worked a governance queue in ten-day improvement cycles — ADR freshness, doc-currency sweeps, parity audits, and the recurring audit/RBAC no-drift re-sweep — executed off-hours, each producing commits and dated verification rows. Crucially, the invariants are enforced by CI, not by hope: the control-dashboard spec makes provider-agnosticism a test — components bind to port-shaped DTOs, never to an @azure/* import and never to an if (kind === 'azure') branch — and that invariant is "an acceptance criterion and a CI grep gate." An architectural rule that isn't mechanically checked is just a preference. Verification was Docker-only throughout — no npm or node on the host — and when GitHub Actions died on billing mid-project, the fallback was a local-CI script that ran the real gates in a container and posted statuses only for checks that genuinely passed.

The process war story

A fleet-wide framework sync kept auto-pushing to the shared branch and reverting the same three files ten times — a store-readiness preflight, a CI helper, and the repo's own identity config. It was caught, restored, escalated, and eventually defended by session-start guard hooks that warn when the file is missing. The lesson generalises: automation that writes to your repo is a dependency with a change budget, and it needs the same guardrails as a human contributor. On the tenth occurrence the fix stopped being "restore it" and became "detect it at boot."

The architecture that could describe itself precisely enough for an agent to build it was the same architecture that could describe itself precisely enough to audit itself.

That is not a coincidence. The no-drift sweep re-enumerated every mutation route and re-graded it against three criteria with no human in the loop. A narrow, explicit contract isn't just cheaper to implement — it is the only kind a machine can check.


12 · THE PROOFWhat shipped, and what didn't

As with everything I write up, this is drawn against real HEADs and PR numbers, not a whiteboard. Production is Next.js Route Handlers on Vercel with MongoDB Atlas. Here is what is shipped and live:

And here is what is designed, specced, and honestly not built — said plainly, because the line between the two is the whole point:

The claim the shipped work supports, at exactly this size and no larger: a user can connect their own cloud account, have the credential sealed at rest, verify it authenticates and is authorized, list their compute inventory through a provider-neutral contract, control a VM behind role-and-confirm gating, and read an immutable ledger of every attempt — with the vendor name appearing in exactly one file.

One small detail that separates a real integration from a plausible one: verifyConnection is two calls, not one. Getting a token proves the service principal authenticates; it does not prove it is authorized on the target subscription. So the adapter does both — acquire the token, then one cheap subscription-scoped resourceGroups.list() — the analogue of AWS's sts:GetCallerIdentity plus a scoped probe. A green tick that only means "your password is right" is worse than no tick.

← All writing rummanahmed.com Share on LinkedIn